gloomberb 0.15.3 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.15.3",
3
+ "version": "0.15.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",
@@ -13,6 +13,7 @@ import type { PluginManager } from "./builtin/plugin-marketplace/store";
13
13
  import { checkPluginCompatibility, readPluginManifest } from "./compat";
14
14
  import { installedPeerPlugins } from "./host-link";
15
15
  import { getPluginCacheDir, getPluginsDir, pluginsMissingHostExports, readPluginCommit } from "./loader";
16
+ import { bunCommand } from "./dependencies";
16
17
 
17
18
  /**
18
19
  * Automatic plugin updates for a process that can run git and bun: the
@@ -65,7 +66,7 @@ function createNodeAutoUpdateDeps(manager: PluginManager): PluginAutoUpdateDeps
65
66
  },
66
67
  remoteHead: async (directory) => (await installer()).readPluginRemoteHead(directory),
67
68
  hasLocalChanges: async (directory) => (await installer()).hasLocalChanges(join(getPluginsDir(), directory)),
68
- hasBun: () => Bun.which("bun") !== null,
69
+ hasBun: () => bunCommand() !== null,
69
70
  update: (directory, pin) => manager.update(directory, pin),
70
71
  log: (message) => log.info(message),
71
72
  };
@@ -36,11 +36,16 @@ function heatmapBaseBackground(): string {
36
36
  return blendHex(colors.panel, colors.bg, HEATMAP_BASE_BLEND);
37
37
  }
38
38
 
39
+ // Diverging: red below zero, green above, and no tint at zero. A yellow
40
+ // midpoint made every moderate positive the same olive, so 0.45 and 0.64
41
+ // could not be told apart.
39
42
  function heatmapSemanticColor(correlation: number): string {
40
- const clamped = Math.max(-1, Math.min(1, correlation));
41
- return clamped < 0
42
- ? blendHex(colors.negative, colors.warning, clamped + 1)
43
- : blendHex(colors.warning, colors.positive, clamped);
43
+ return correlation < 0 ? colors.negative : colors.positive;
44
+ }
45
+
46
+ /** How far a cell leans toward its hue: none at zero, strongest at +/-1. */
47
+ function heatmapTargetStrength(correlation: number): number {
48
+ return 0.06 + Math.abs(correlation) * 0.56;
44
49
  }
45
50
 
46
51
  function heatmapTintSteps(targetStrength: number): number[] {
@@ -52,10 +57,15 @@ function readableForeground(background: string): string {
52
57
  return highestContrast(textCandidates(), background);
53
58
  }
54
59
 
55
- export function resolveCorrelationHeatmapCellColors(correlation: number | null): CorrelationHeatmapCellColors {
60
+ export function resolveCorrelationHeatmapCellColors(
61
+ correlation: number | null,
62
+ options: { diagonal?: boolean } = {},
63
+ ): CorrelationHeatmapCellColors {
56
64
  const baseBackground = heatmapBaseBackground();
57
65
 
58
- if (correlation === null) {
66
+ // A series against itself is always 1.00 and says nothing: keep it quiet so
67
+ // the eye goes to the pairs.
68
+ if (correlation === null || options.diagonal) {
59
69
  const muted = colors.textMuted;
60
70
  return {
61
71
  background: baseBackground,
@@ -67,7 +77,7 @@ export function resolveCorrelationHeatmapCellColors(correlation: number | null):
67
77
 
68
78
  const clamped = Math.max(-1, Math.min(1, correlation));
69
79
  const semanticColor = heatmapSemanticColor(clamped);
70
- const targetStrength = 0.3 + Math.abs(clamped) * 0.26;
80
+ const targetStrength = heatmapTargetStrength(clamped);
71
81
  let fallback: CorrelationHeatmapCellColors | null = null;
72
82
 
73
83
  for (const tintStrength of heatmapTintSteps(targetStrength)) {
@@ -285,7 +285,7 @@ function CorrelationMatrixPane({ focused, width, height }: PaneProps) {
285
285
  {/* Cells */}
286
286
  {symbols.map((colSym) => {
287
287
  const r = matrix.results.get(pairKey(rowSym, colSym))?.correlation ?? null;
288
- const cellColors = resolveCorrelationHeatmapCellColors(r);
288
+ const cellColors = resolveCorrelationHeatmapCellColors(r, { diagonal: rowSym === colSym });
289
289
  const text = formatCorrelation(r);
290
290
  return (
291
291
  <Box
@@ -1,10 +1,12 @@
1
- import { t } from "../../../../i18n";
1
+ import type { ReactNode } from "react";
2
+ import { ExternalLinkText, usePaneLinkMenuEntry } from "../../../../components";
3
+ import { t, tf } from "../../../../i18n";
2
4
  import { formatMarketPriceWithCurrency, liveQuoteFormatOptions, type MarketFormatOptions } from "../../../../market-data/market/format";
3
- import { colors, priceColor } from "../../../../theme/colors";
5
+ import { colors, hoverBg, priceColor } from "../../../../theme/colors";
4
6
  import type { Quote } from "../../../../types/financials";
5
7
  import { Box, Text, useUiCapabilities } from "../../../../ui";
6
- import { displayWidth, formatNumber, padTo } from "../../../../utils/format";
7
- import type { PositionTableRow, StatField } from "./types";
8
+ import { displayWidth, formatNumber, padTo, truncateToDisplayWidth } from "../../../../utils/format";
9
+ import type { OverviewFunctionLink, PositionTableRow, StatField } from "./types";
8
10
  import { portfolioPnlLabel } from "../../portfolio-list/position-metrics";
9
11
 
10
12
  const STAT_COLUMN_GAP = 2;
@@ -190,8 +192,70 @@ export function fundamentalsGridColumns(width: number): number {
190
192
  return Math.max(2, Math.min(4, Math.ceil((width + STAT_COLUMN_GAP) / (FUNDAMENTALS_MAX_COLUMN_WIDTH + STAT_COLUMN_GAP))));
191
193
  }
192
194
 
193
- export function FundamentalsGrid({ fields, width }: { fields: StatField[]; width: number }) {
194
- const columnCount = fundamentalsGridColumns(width);
195
+ function gridLabelWidth(colWidth: number): number {
196
+ return Math.min(STAT_LABEL_WIDTH, Math.max(8, Math.floor(colWidth * 0.45)));
197
+ }
198
+
199
+ /**
200
+ * Columns for words rather than figures (an industry, a website): the most
201
+ * the grid allows at which every value reads whole. A title that may be cut
202
+ * (a SIC description) does not count.
203
+ */
204
+ export function textGridColumns(fields: StatField[], width: number): number {
205
+ const widest = Math.max(0, ...fields.map((field) => displayWidth(field.value)));
206
+ for (let columns = fundamentalsGridColumns(width); columns > 1; columns -= 1) {
207
+ const colWidth = Math.floor((width - STAT_COLUMN_GAP * (columns - 1)) / columns);
208
+ if (colWidth - gridLabelWidth(colWidth) >= widest) return columns;
209
+ }
210
+ return 1;
211
+ }
212
+
213
+ /** A figure that opens its research function: the whole cell is the target. */
214
+ function LinkedStatCell({
215
+ link,
216
+ width,
217
+ onOpen,
218
+ children,
219
+ }: {
220
+ link: OverviewFunctionLink;
221
+ width: number;
222
+ onOpen: (link: OverviewFunctionLink) => void;
223
+ children: ReactNode;
224
+ }) {
225
+ // The pane menu lists each function once, so the keyboard reaches what a click opens.
226
+ usePaneLinkMenuEntry(`function:${link.templateId}`, tf("Open {label}", { label: t(link.name) }), () => onOpen(link));
227
+ return (
228
+ <Box
229
+ width={width}
230
+ flexDirection="row"
231
+ hoverBackgroundColor={hoverBg()}
232
+ onMouseDown={(event?: { button?: number }) => {
233
+ if ((event?.button ?? 0) === 0) onOpen(link);
234
+ }}
235
+ cursor="pointer"
236
+ data-gloom-interactive="true"
237
+ data-gloom-label={tf("Open {label}", { label: t(link.name) })}
238
+ data-gloom-ui="key-value-row"
239
+ >
240
+ {children}
241
+ </Box>
242
+ );
243
+ }
244
+
245
+ export function FundamentalsGrid({
246
+ fields,
247
+ width,
248
+ columns,
249
+ onOpenLink,
250
+ }: {
251
+ fields: StatField[];
252
+ width: number;
253
+ /** Fixed column count; by default as many as keep each value near its label. */
254
+ columns?: number;
255
+ /** Opens a figure's research function; without it figures are plain. */
256
+ onOpenLink?: (link: OverviewFunctionLink) => void;
257
+ }) {
258
+ const columnCount = columns ?? fundamentalsGridColumns(width);
195
259
  const availableWidth = width - STAT_COLUMN_GAP * (columnCount - 1);
196
260
  const baseColWidth = Math.floor(availableWidth / columnCount);
197
261
  const extraWidth = availableWidth - baseColWidth * columnCount;
@@ -215,19 +279,34 @@ export function FundamentalsGrid({ fields, width }: { fields: StatField[]; width
215
279
  </Box>
216
280
  );
217
281
  }
218
- const labelWidth = Math.min(STAT_LABEL_WIDTH, Math.max(8, Math.floor(colWidth * 0.45)));
282
+ const labelWidth = gridLabelWidth(colWidth);
219
283
  const valueWidth = Math.max(1, colWidth - labelWidth);
284
+ // A long value (an industry, a SIC title) is cut from the end, never from the label side.
285
+ const value = truncateToDisplayWidth(field.value, valueWidth);
286
+ const detailRoom = valueWidth - displayWidth(value) - 1;
287
+ const details = typeof field.detail === "string" ? [field.detail] : field.detail ?? [];
288
+ // A cut "in 4..." misleads; a title cut to "Semicond..." still names it.
289
+ const detail = details.find((text) => displayWidth(text) <= detailRoom)
290
+ ?? (field.clipDetail && details[0] && detailRoom >= 4 ? truncateToDisplayWidth(details[0], detailRoom) : "");
291
+ const content = (
292
+ <>
293
+ <Box width={labelWidth} overflow="hidden">
294
+ <Text fg={colors.textDim}>{t(field.label)}</Text>
295
+ </Box>
296
+ <Box flexDirection="row" width={valueWidth} justifyContent="flex-end" overflow="hidden">
297
+ {field.url
298
+ ? <ExternalLinkText url={field.url} label={value} color={field.valueColor ?? colors.text} />
299
+ : <Text fg={field.valueColor ?? colors.text}>{value}</Text>}
300
+ {detail ? <Text fg={colors.textDim}>{` ${detail}`}</Text> : null}
301
+ </Box>
302
+ </>
303
+ );
220
304
  return (
221
305
  <Box key={j} flexDirection="row">
222
306
  {j > 0 && <Box width={STAT_COLUMN_GAP} />}
223
- <Box width={colWidth} flexDirection="row" data-gloom-ui="key-value-row">
224
- <Box width={labelWidth} overflow="hidden">
225
- <Text fg={colors.textDim}>{t(field.label)}</Text>
226
- </Box>
227
- <Box flexDirection="row" width={valueWidth} justifyContent="flex-end" overflow="hidden">
228
- <Text fg={field.valueColor ?? colors.text}>{field.value}</Text>
229
- </Box>
230
- </Box>
307
+ {field.link && onOpenLink
308
+ ? <LinkedStatCell link={field.link} width={colWidth} onOpen={onOpenLink}>{content}</LinkedStatCell>
309
+ : <Box width={colWidth} flexDirection="row" data-gloom-ui="key-value-row">{content}</Box>}
231
310
  </Box>
232
311
  );
233
312
  })}
@@ -1,7 +1,7 @@
1
1
  import { formatPriceEarnings } from "../../../../utils/price-earnings";
2
2
  import { convertMarketCapitalization } from "../../../../utils/market-capitalization";
3
3
  import { priceColor } from "../../../../theme/colors";
4
- import type { Quote, TickerFinancials } from "../../../../types/financials";
4
+ import type { CompanyProfile, NextEarnings, Quote, TickerFinancials } from "../../../../types/financials";
5
5
  import type { TickerPosition, TickerRecord } from "../../../../types/ticker";
6
6
  import {
7
7
  formatCompact,
@@ -18,13 +18,42 @@ import {
18
18
  formatMarketQuantity,
19
19
  withCurrencyMinorDigits,
20
20
  } from "../../../../market-data/market/format";
21
- import type { PositionTableRow, StatField } from "./types";
21
+ 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 { formatShortDate } from "../../../../utils/datetime-format";
26
+ import { safeExternalUrl } from "../../../../utils/external-url";
25
27
 
26
28
  type CurrencyConverter = (value: number, fromCurrency: string) => number;
27
29
 
30
+ const RELATIONSHIP_GRAPH: OverviewFunctionLink = { name: "Relationship Graph", templateId: "relationship-graph-pane" };
31
+ const HOLDERS: OverviewFunctionLink = { name: "Holders", tabId: "holders", templateId: "holders-pane" };
32
+ const SHORT_INTEREST: OverviewFunctionLink = { name: "Short Interest", tabId: "short-interest", templateId: "short-interest-pane" };
33
+ const EARNINGS: OverviewFunctionLink = { name: "Earnings", templateId: "earnings-calendar-pane" };
34
+ const DIVIDENDS: OverviewFunctionLink = { name: "Dividends", tabId: "dividend-yield", templateId: "dividend-yield-pane" };
35
+
36
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
37
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
38
+
39
+ const finite = (value: number | undefined): value is number => value != null && Number.isFinite(value);
40
+
41
+ /** Whole calendar days from `today` to `date`, both YYYY-MM-DD. */
42
+ function daysUntil(date: string, today: string): number {
43
+ return Math.round((Date.parse(`${date}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000);
44
+ }
45
+
46
+ /** "Sep 10", or "Dec 12, 25" in another year. */
47
+ function calendarDate(date: string, today: string): string {
48
+ return formatShortDate(`${date}T00:00:00Z`, { year: date.slice(0, 4) === today.slice(0, 4) ? false : "2-digit", utc: true });
49
+ }
50
+
51
+ /** "in 49d", then "49d" for a narrow column. */
52
+ function daysAhead(days: number, prefix?: string): string[] {
53
+ const lead = prefix ? `${prefix} ` : "";
54
+ return days === 0 ? [`${lead}today`] : [`${lead}in ${days}d`, `${lead}${days}d`];
55
+ }
56
+
28
57
  function compactPositionAccount(position: TickerPosition): string {
29
58
  const rawAccount = position.brokerAccountId || position.portfolio;
30
59
  const isBrokerPortfolio = rawAccount.startsWith("broker:");
@@ -42,6 +71,8 @@ export function buildOverviewStats({
42
71
  quoteCurrency,
43
72
  baseCurrency,
44
73
  marketCapExchangeRates = new Map(),
74
+ nextEarnings,
75
+ today,
45
76
  }: {
46
77
  quote: Quote | undefined;
47
78
  fundamentals: TickerFinancials["fundamentals"] | undefined;
@@ -49,6 +80,9 @@ export function buildOverviewStats({
49
80
  baseCurrency: string;
50
81
  toBase: CurrencyConverter;
51
82
  marketCapExchangeRates?: ReadonlyMap<string, number>;
83
+ nextEarnings?: NextEarnings;
84
+ /** The listing's calendar day, YYYY-MM-DD; dates before it are left out. */
85
+ today?: string;
52
86
  }): StatField[] {
53
87
  const stats: StatField[] = [];
54
88
  const money = (value: number, perShare = false) => formatReportedMoney(value, fundamentals?.financialCurrency, perShare);
@@ -73,6 +107,9 @@ export function buildOverviewStats({
73
107
  if (fundamentals?.sharesOutstanding) {
74
108
  stats.push({ label: "Shares Out", value: formatCompact(fundamentals.sharesOutstanding) });
75
109
  }
110
+ if (finite(fundamentals?.floatShares) && fundamentals.floatShares > 0) {
111
+ stats.push({ label: "Float", value: formatCompact(fundamentals.floatShares) });
112
+ }
76
113
  const trailingPE = liveTrailingPE(quote, fundamentals);
77
114
  if (trailingPE != null) {
78
115
  stats.push({ label: "P/E (TTM)", value: formatPriceEarnings(trailingPE) });
@@ -87,11 +124,31 @@ export function buildOverviewStats({
87
124
  if (fundamentals?.pegRatio != null) {
88
125
  stats.push({ label: "PEG", value: formatNumber(fundamentals.pegRatio, 2) });
89
126
  }
127
+ // A report the payload still lists after its day has passed is not the next one.
128
+ if (today && nextEarnings && ISO_DATE.test(nextEarnings.date) && nextEarnings.date >= today) {
129
+ const timing = nextEarnings.timing?.toUpperCase();
130
+ stats.push({
131
+ label: "Earnings",
132
+ value: calendarDate(nextEarnings.date, today),
133
+ detail: daysAhead(daysUntil(nextEarnings.date, today), timing),
134
+ link: EARNINGS,
135
+ });
136
+ }
90
137
  const dividendYield = liveDividendYield(quote, fundamentals);
91
138
  if (fundamentals && dividendYield != null) {
92
139
  const label = fundamentals.dividendYieldBasis === "forward" ? "Fwd Div Yld" : fundamentals.dividendYieldBasis === "trailing" ? "TTM Div Yld" : "Div Yield";
93
140
  stats.push({ label, value: formatLevelPercent(dividendYield) });
94
141
  }
142
+ const exDividendDate = fundamentals?.exDividendDate;
143
+ if (exDividendDate && ISO_DATE.test(exDividendDate)) {
144
+ const days = today ? daysUntil(exDividendDate, today) : -1;
145
+ stats.push({
146
+ label: "Ex-Dividend",
147
+ value: calendarDate(exDividendDate, today ?? exDividendDate),
148
+ detail: days >= 0 ? daysAhead(days) : undefined,
149
+ link: DIVIDENDS,
150
+ });
151
+ }
95
152
  if (fundamentals?.revenue != null) {
96
153
  stats.push({ label: "Revenue", value: money(fundamentals.revenue) });
97
154
  }
@@ -119,10 +176,59 @@ export function buildOverviewStats({
119
176
  } else if (fundamentals?.enterpriseValue != null) {
120
177
  stats.push({ label: "EV", value: formatCompactCurrency(fundamentals.enterpriseValue, quoteCurrency) });
121
178
  }
179
+ if (finite(fundamentals?.beta)) {
180
+ stats.push({ label: "Beta", value: formatNumber(fundamentals.beta, 2), link: RELATIONSHIP_GRAPH });
181
+ }
182
+ if (finite(fundamentals?.shortPercentOfFloat)) {
183
+ stats.push({
184
+ label: "Short Float",
185
+ value: formatLevelPercent(fundamentals.shortPercentOfFloat),
186
+ detail: finite(fundamentals.shortRatio) ? `${formatNumber(fundamentals.shortRatio, 1)}d to cover` : undefined,
187
+ link: SHORT_INTEREST,
188
+ });
189
+ }
190
+ if (finite(fundamentals?.institutionPercentHeld)) {
191
+ stats.push({ label: "Inst Own", value: formatLevelPercent(fundamentals.institutionPercentHeld), link: HOLDERS });
192
+ }
193
+ if (finite(fundamentals?.insiderPercentHeld)) {
194
+ stats.push({ label: "Insider Own", value: formatLevelPercent(fundamentals.insiderPercentHeld), link: HOLDERS });
195
+ }
122
196
 
123
197
  return stats;
124
198
  }
125
199
 
200
+ /** Who the company is: instrument type, classification, head count, site and identifiers. */
201
+ export function buildProfileFields({
202
+ instrumentType,
203
+ sector,
204
+ industry,
205
+ profile,
206
+ isin,
207
+ }: {
208
+ instrumentType?: string;
209
+ sector?: string;
210
+ industry?: string;
211
+ profile?: CompanyProfile;
212
+ isin?: string;
213
+ }): StatField[] {
214
+ const fields: StatField[] = [];
215
+ if (instrumentType) fields.push({ label: "Type", value: instrumentType });
216
+ if (sector) fields.push({ label: "Sector", value: sector });
217
+ if (industry) fields.push({ label: "Industry", value: industry });
218
+ if (finite(profile?.employees) && profile.employees > 0) {
219
+ fields.push({ label: "Employees", value: formatNumber(profile.employees, 0) });
220
+ }
221
+ const fiscalMonth = MONTHS[Number(/^(\d{2})-\d{2}$/.exec(profile?.fiscalYearEnd ?? "")?.[1]) - 1];
222
+ if (fiscalMonth) fields.push({ label: "FY End", value: fiscalMonth });
223
+ if (profile?.sic) fields.push({ label: "SIC", value: profile.sic, detail: profile.sicDescription, clipDetail: true });
224
+ const website = profile?.website ? safeExternalUrl(profile.website) : null;
225
+ if (website) {
226
+ fields.push({ label: "Website", value: new URL(website).hostname.replace(/^www\./, ""), url: website });
227
+ }
228
+ if (isin) fields.push({ label: "ISIN", value: isin });
229
+ return fields;
230
+ }
231
+
126
232
  export function buildPositionRows({
127
233
  ticker,
128
234
  quote,
@@ -6,8 +6,11 @@ import {
6
6
  usePaneInstance,
7
7
  usePaneTicker,
8
8
  } from "../../../../state/app/context";
9
+ import { usePluginAppActions } from "../../../runtime";
9
10
  import { getTickerResearchPaneSettings } from "../settings";
10
11
  import { OverviewTab } from "../overview-tab";
12
+ import { useOpenResearchTab } from "../research-tab-navigation";
13
+ import type { OverviewFunctionLink } from "./types";
11
14
 
12
15
  /** Registered by the chart composer, in the same plugin as this tab. */
13
16
  const CHART_TAB_ID = "chart";
@@ -17,11 +20,19 @@ export function OverviewResearchTab({ width, focused }: TickerResearchTabProps)
17
20
  const dispatch = useAppDispatch();
18
21
  const paneId = useOptionalPaneInstanceId();
19
22
  const paneInstance = usePaneInstance();
23
+ const openResearchTab = useOpenResearchTab();
24
+ const { createPaneFromTemplate } = usePluginAppActions();
20
25
  // A pane locked to one tab has nowhere to go.
21
26
  const lockedToOneTab = getTickerResearchPaneSettings(paneInstance?.settings).hideTabs;
22
27
  const openChart = useCallback(() => {
23
28
  if (paneId) dispatch({ type: "UPDATE_PANE_STATE", paneId, patch: { activeTabId: CHART_TAB_ID } });
24
29
  }, [dispatch, paneId]);
30
+ // A figure opens its function here when this pane has that tab, and in its own pane otherwise.
31
+ const openFunction = useCallback((link: OverviewFunctionLink) => {
32
+ if (link.tabId && openResearchTab?.(link.tabId)) return;
33
+ const symbol = ticker?.metadata.ticker;
34
+ if (symbol) createPaneFromTemplate(link.templateId, { symbol, symbols: [symbol], ticker });
35
+ }, [createPaneFromTemplate, openResearchTab, ticker]);
25
36
  return (
26
37
  <OverviewTab
27
38
  width={width}
@@ -29,6 +40,7 @@ export function OverviewResearchTab({ width, focused }: TickerResearchTabProps)
29
40
  ticker={ticker}
30
41
  financials={financials}
31
42
  onOpenChart={paneId && !lockedToOneTab ? openChart : undefined}
43
+ onOpenFunction={openFunction}
32
44
  />
33
45
  );
34
46
  }
@@ -1,7 +1,28 @@
1
+ /**
2
+ * The research function a figure opens: the Ticker Research tab that shows
3
+ * it when this pane has one, otherwise the function's own pane.
4
+ */
5
+ export interface OverviewFunctionLink {
6
+ /** Named in the pane menu ("Open Holders"). */
7
+ name: string;
8
+ tabId?: string;
9
+ templateId: string;
10
+ }
11
+
1
12
  export interface StatField {
2
13
  label: string;
3
14
  value: string;
4
15
  valueColor?: string;
16
+ /**
17
+ * Muted context after the value (days to cover, days to go), shown whole or
18
+ * not at all; a list offers shorter forms for narrow columns.
19
+ */
20
+ detail?: string | readonly string[];
21
+ /** Descriptive context (a SIC title) that may be cut to fit instead. */
22
+ clipDetail?: boolean;
23
+ link?: OverviewFunctionLink;
24
+ /** An address the value opens in the browser. */
25
+ url?: string;
5
26
  }
6
27
 
7
28
  export interface PositionTableRow {
@@ -1,4 +1,4 @@
1
- import { EmptyState, SectionHeading, usePaneNoticeFooter } from "../../../components";
1
+ import { EmptyState, PaneLinkMenu, SectionHeading, usePaneNoticeFooter } from "../../../components";
2
2
  import { CompositeChart, pricePointsToResolvedSeries } from "../../../components/chart/composite";
3
3
  import { CompanyLogo } from "../../../components/company-logo";
4
4
  import { PriceReturnStrip } from "../../../components/price-performance";
@@ -16,8 +16,10 @@ import type { TickerRecord } from "../../../types/ticker";
16
16
  import { Box, ScrollBox, Text, TextAttributes, useUiCapabilities } from "../../../ui";
17
17
  import { resolveExchangeTimeZone } from "../../../utils/exchanges";
18
18
  import { convertCurrency, displayWidth, formatPercentRaw, truncateToDisplayWidth } from "../../../utils/format";
19
- import { CompactRangeBar, FundamentalsGrid, PositionTable, QuoteBook } from "./overview/components";
20
- import { buildOverviewStats, buildPositionRows } from "./overview/model";
19
+ import { zonedDateKey } from "../../../utils/zoned-date-time";
20
+ import { CompactRangeBar, FundamentalsGrid, PositionTable, QuoteBook, textGridColumns } from "./overview/components";
21
+ import { buildOverviewStats, buildPositionRows, buildProfileFields } from "./overview/model";
22
+ import type { OverviewFunctionLink } from "./overview/types";
21
23
  import { describeFundamentalMarketCap } from "../../../utils/market-capitalization";
22
24
  import { liveFiftyTwoWeekRange, liveMarketCapitalization } from "../portfolio-list/live-valuation";
23
25
 
@@ -28,6 +30,8 @@ interface OverviewTabProps {
28
30
  financials: TickerFinancials | null;
29
31
  /** A click on the price chart opens the full chart. */
30
32
  onOpenChart?: () => void;
33
+ /** A click on a figure opens its research function (beta to GR, holders to HDS). */
34
+ onOpenFunction?: (link: OverviewFunctionLink) => void;
31
35
  }
32
36
 
33
37
  export function OverviewTab(props: OverviewTabProps) {
@@ -35,7 +39,7 @@ export function OverviewTab(props: OverviewTabProps) {
35
39
  return <ResolvedOverviewTab {...props} ticker={props.ticker} />;
36
40
  }
37
41
 
38
- function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpenChart }: OverviewTabProps & { ticker: TickerRecord }) {
42
+ function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpenChart, onOpenFunction }: OverviewTabProps & { ticker: TickerRecord }) {
39
43
  const baseCurrency = useAppSelector((state) => state.config.baseCurrency);
40
44
  const { width: termWidth } = useViewport();
41
45
  const { fractionalViewport = false, nativePaneChrome } = useUiCapabilities();
@@ -134,6 +138,16 @@ function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpe
134
138
  baseCurrency,
135
139
  toBase,
136
140
  marketCapExchangeRates: exchangeRates,
141
+ nextEarnings: financials?.nextEarnings,
142
+ // Report and ex-dividend dates are the listing's calendar days.
143
+ today: zonedDateKey(Date.now(), chartTimeZone ?? "America/New_York"),
144
+ });
145
+ const profileFields = buildProfileFields({
146
+ instrumentType,
147
+ sector,
148
+ industry,
149
+ profile,
150
+ isin: ticker.metadata.isin,
137
151
  });
138
152
  const performanceFields = buildPriceReturnFields(
139
153
  appendQuoteToPriceReturnHistory(financials?.priceHistory ?? [], historyQuote),
@@ -280,7 +294,9 @@ function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpe
280
294
  {stats.length > 0 && (
281
295
  <Box flexDirection="column">
282
296
  <SectionHeading title={t("Fundamentals")} />
283
- <FundamentalsGrid fields={stats} width={contentWidth} />
297
+ <PaneLinkMenu>
298
+ <FundamentalsGrid fields={stats} width={contentWidth} onOpenLink={onOpenFunction} />
299
+ </PaneLinkMenu>
284
300
  </Box>
285
301
  )}
286
302
 
@@ -291,35 +307,12 @@ function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpe
291
307
  </Box>
292
308
  )}
293
309
 
294
- {/* Sector / Industry / Type */}
295
- {(sector || industry || instrumentType) && (
296
- <Box flexDirection="row" height={1} gap={3}>
297
- {instrumentType && (
298
- <Box flexDirection="row">
299
- <Text fg={colors.textDim}>{t("Type")}: </Text>
300
- <Text fg={colors.text}>{instrumentType}</Text>
301
- </Box>
302
- )}
303
- {sector && (
304
- <Box flexDirection="row">
305
- <Text fg={colors.textDim}>{t("Sector")}: </Text>
306
- <Text fg={colors.text}>{sector}</Text>
307
- </Box>
308
- )}
309
- {industry && (
310
- <Box flexDirection="row">
311
- <Text fg={colors.textDim}>{t("Industry")}: </Text>
312
- <Text fg={colors.text}>{industry}</Text>
313
- </Box>
314
- )}
315
- </Box>
316
- )}
317
-
318
- {/* ISIN */}
319
- {ticker.metadata.isin && (
320
- <Box flexDirection="row" height={1}>
321
- <Text fg={colors.textDim}>{t("ISIN")}: </Text>
322
- <Text fg={colors.text}>{ticker.metadata.isin}</Text>
310
+ {profileFields.length > 0 && (
311
+ <Box flexDirection="column">
312
+ <SectionHeading title={t("Profile")} />
313
+ <PaneLinkMenu>
314
+ <FundamentalsGrid fields={profileFields} width={contentWidth} columns={textGridColumns(profileFields, contentWidth)} />
315
+ </PaneLinkMenu>
323
316
  </Box>
324
317
  )}
325
318
 
@@ -35,6 +35,7 @@ import { parsePublicTickerKey } from "../../../utils/exchanges";
35
35
  import { tickerHasYahooSuffix } from "../../../sources/yahoo-finance/symbols";
36
36
  import { tickerQuoteFooterInfo } from "./quote-footer";
37
37
  import { ResearchTabKeysProvider, useResearchTabKeysHost } from "./research-tab-keys";
38
+ import { ResearchTabNavigationProvider } from "./research-tab-navigation";
38
39
 
39
40
  const TICKER_RESEARCH_TAB_COMMIT_DELAY_MS = 120;
40
41
  /** A tab counts as viewed once it stays open this long, not when h/l passes over it. */
@@ -261,6 +262,12 @@ export function TickerResearchPane({ focused, width, height }: PaneProps) {
261
262
  }, [focused, registry, resolvedTabId, showTabs]);
262
263
  const contentHeight = Math.max(1, height - tabBarHeight);
263
264
  const visibleTabIds = useMemo(() => new Set(allTabs.map((tab) => tab.id)), [visibleTabIdKey]);
265
+ // A figure on one tab can open the tab that explains it (holders, short interest).
266
+ const openResearchTab = useCallback((tabId: string) => {
267
+ if (!visibleTabIds.has(tabId)) return false;
268
+ setActiveTabId(tabId);
269
+ return true;
270
+ }, [setActiveTabId, visibleTabIds]);
264
271
  const renderedTabIds = useMemo(() => {
265
272
  const next = new Set<string>();
266
273
  for (const tabId of mountedTabIds) {
@@ -331,12 +338,14 @@ export function TickerResearchPane({ focused, width, height }: PaneProps) {
331
338
  >
332
339
  <PaneFooterScope active={isActive}>
333
340
  <NestedPaneTabs>
334
- <TickerResearchTab
335
- width={width}
336
- height={contentHeight}
337
- focused={focused && isActive}
338
- onCapture={isActive ? handlePluginCapture : ignorePluginCapture}
339
- />
341
+ <ResearchTabNavigationProvider value={showTabs ? openResearchTab : null}>
342
+ <TickerResearchTab
343
+ width={width}
344
+ height={contentHeight}
345
+ focused={focused && isActive}
346
+ onCapture={isActive ? handlePluginCapture : ignorePluginCapture}
347
+ />
348
+ </ResearchTabNavigationProvider>
340
349
  </NestedPaneTabs>
341
350
  </PaneFooterScope>
342
351
  </Box>
@@ -0,0 +1,14 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /** Selects a research tab and says whether this pane shows it for the ticker. */
4
+ export type OpenResearchTab = (tabId: string) => boolean;
5
+
6
+ const ResearchTabNavigationContext = createContext<OpenResearchTab | null>(null);
7
+
8
+ /** `null` while the pane is locked to one tab. */
9
+ export const ResearchTabNavigationProvider = ResearchTabNavigationContext.Provider;
10
+
11
+ /** Null outside the research pane, or while it has no tab strip to move. */
12
+ export function useOpenResearchTab(): OpenResearchTab | null {
13
+ return useContext(ResearchTabNavigationContext);
14
+ }
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { linkHostPackages } from "./host-link";
4
+
5
+ /**
6
+ * The Bun that installs a plugin's dependencies, and the environment to run
7
+ * it in. A packaged desktop app and the compiled terminal usually have no
8
+ * `bun` on PATH (an app opened from the Finder gets a minimal PATH), and the
9
+ * install then failed quietly, leaving plugins such as BYOK AI without their
10
+ * packages. The running executable is Bun either way: the desktop's Bun
11
+ * process is the bun binary itself, and a compiled binary acts as the Bun CLI
12
+ * when BUN_BE_BUN is set.
13
+ */
14
+ export function bunCommand(): { command: string; env?: NodeJS.ProcessEnv } | null {
15
+ const onPath = typeof Bun !== "undefined" ? Bun.which("bun") : null;
16
+ if (onPath) return { command: onPath };
17
+ if (typeof Bun === "undefined") return null;
18
+ return { command: process.execPath, env: { ...process.env, BUN_BE_BUN: "1" } };
19
+ }
20
+
21
+ /** Runtime dependencies a plugin declares that are not in its `node_modules`. */
22
+ export function missingPluginDependencies(pluginDir: string): string[] {
23
+ let dependencies: unknown;
24
+ try {
25
+ dependencies = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8")).dependencies;
26
+ } catch {
27
+ return [];
28
+ }
29
+ if (!dependencies || typeof dependencies !== "object") return [];
30
+ return Object.keys(dependencies).filter((name) => !existsSync(join(pluginDir, "node_modules", name, "package.json")));
31
+ }
32
+
33
+ /**
34
+ * Installs the dependencies of a plugin that was installed without them, and
35
+ * relinks `gloomberb` and `react`, which `bun install` prunes. Runs through
36
+ * `Bun.spawn` so the loader, which the desktop view also bundles, pulls in no
37
+ * Node process module.
38
+ */
39
+ export async function installPluginDependencies(pluginDir: string): Promise<boolean> {
40
+ const bun = bunCommand();
41
+ if (!bun) return false;
42
+ let code: number | null = null;
43
+ try {
44
+ const child = Bun.spawn([bun.command, "install", "--production"], {
45
+ cwd: pluginDir,
46
+ env: bun.env ?? process.env,
47
+ stdout: "ignore",
48
+ stderr: "ignore",
49
+ });
50
+ code = await child.exited;
51
+ } catch {
52
+ code = null;
53
+ }
54
+ linkHostPackages(pluginDir);
55
+ return code === 0;
56
+ }
@@ -1,5 +1,5 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync } from "fs";
2
- import { dirname, join, resolve } from "path";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, type Stats } from "fs";
2
+ import { basename, dirname, join, resolve } from "path";
3
3
 
4
4
  import { installPluginHostResolver } from "./host-resolver";
5
5
  import { isPluginPackageName, pluginDirectoryNames } from "./plugin-names";
@@ -88,18 +88,34 @@ export function hostPublicModules(): ReadonlyMap<string, string> {
88
88
  function linkTarget(hostRoot: string, pkg: string): string | null {
89
89
  if (pkg === "gloomberb") return hostRoot;
90
90
  const candidate = join(hostRoot, "node_modules", pkg);
91
- return existsSync(candidate) ? candidate : null;
91
+ if (existsSync(candidate)) return candidate;
92
+ // A global install hoists dependencies beside the package rather than
93
+ // inside it (`bun add -g` keeps one flat node_modules), so `react` is found
94
+ // the way the host itself resolves it.
95
+ try {
96
+ return dirname(Bun.resolveSync(`${pkg}/package.json`, hostRoot));
97
+ } catch {
98
+ return null;
99
+ }
92
100
  }
93
101
 
94
- /** True when `path` is already a symlink pointing at `target`. */
102
+ /** True when `path` is already a link pointing at `target`. Compared by real
103
+ * path: Windows spells a junction's target its own way. */
95
104
  function alreadyLinked(path: string, target: string): boolean {
96
105
  try {
97
- return lstatSync(path).isSymbolicLink() && resolve(dirname(path), readlinkSync(path)) === resolve(target);
106
+ return isLink(path, lstatSync(path)) && realpathSync(path) === realpathSync(target);
98
107
  } catch {
99
108
  return false;
100
109
  }
101
110
  }
102
111
 
112
+ /** A symlink or a junction. A directory whose real path is somewhere else is
113
+ * treated as a link too, so a recursive delete never walks into its target. */
114
+ function isLink(path: string, stats: Stats): boolean {
115
+ if (stats.isSymbolicLink()) return true;
116
+ return stats.isDirectory() && realpathSync(path) !== join(realpathSync(dirname(path)), basename(path));
117
+ }
118
+
103
119
  /**
104
120
  * Makes `linkPath` a directory symlink to `target`, replacing whatever is
105
121
  * there. A real directory means `bun install` fetched a second copy; replacing
@@ -109,10 +125,13 @@ function alreadyLinked(path: string, target: string): boolean {
109
125
  function ensureDirLink(linkPath: string, target: string): void {
110
126
  if (alreadyLinked(linkPath, target)) return;
111
127
  mkdirSync(dirname(linkPath), { recursive: true });
112
- if (existsSync(linkPath) || lstatSync(linkPath, { throwIfNoEntry: false })) {
113
- rmSync(linkPath, { recursive: true, force: true });
114
- }
115
- symlinkSync(target, linkPath, "dir");
128
+ const existing = lstatSync(linkPath, { throwIfNoEntry: false });
129
+ if (existing && isLink(linkPath, existing)) unlinkSync(linkPath);
130
+ else if (existing) rmSync(linkPath, { recursive: true, force: true });
131
+ // A directory symlink on Windows needs Developer Mode or admin rights, and
132
+ // without them every external plugin lost `gloomberb` and `react`. A
133
+ // junction needs neither; other platforms ignore the type.
134
+ symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
116
135
  }
117
136
 
118
137
  /**
@@ -4,6 +4,7 @@ import { execFile as execFileCallback, execFileSync, spawn } from "child_process
4
4
  import { promisify } from "util";
5
5
  import { findAbsorbedPlugin, type AbsorbedPlugin } from "./absorbed";
6
6
  import type { PluginPin } from "./builtin/plugin-marketplace/store";
7
+ import { bunCommand } from "./dependencies";
7
8
  import { linkHostPackages } from "./host-link";
8
9
  import {
9
10
  findAbsorbedCheckout,
@@ -241,7 +242,10 @@ async function installDependencies(targetDir: string, quiet: boolean): Promise<v
241
242
  // symlinked in instead, and pulling a second full copy here would both
242
243
  // waste a lot of disk and risk a duplicate React.
243
244
  const output = quiet ? "pipe" : "inherit";
244
- const result = await run("bun", ["install", "--production"], targetDir, { stdout: output, stderr: output }).catch(() => null);
245
+ const bun = bunCommand();
246
+ const result = bun
247
+ ? await run(bun.command, ["install", "--production"], targetDir, { stdout: output, stderr: output }, bun.env).catch(() => null)
248
+ : null;
245
249
  if (result?.code !== 0 && !quiet) console.error(cliStyles.warning("Warning: failed to install plugin dependencies."));
246
250
 
247
251
  // After `bun install`, which prunes links it does not know about.
@@ -7,6 +7,7 @@ import { debugLog } from "../utils/debug-log";
7
7
  import { reportCrash } from "../telemetry/crash-reports";
8
8
  import { findAbsorbedPlugin, type AbsorbedPlugin } from "./absorbed";
9
9
  import { checkPluginCompatibility, explainPluginLoadError, findMissingHostExport, pluginSourceFiles, readPluginManifest } from "./compat";
10
+ import { installPluginDependencies, missingPluginDependencies } from "./dependencies";
10
11
  import { linkHostPackages } from "./host-link";
11
12
  import { pluginFromModule, pluginSupportsTarget } from "./plugin-export";
12
13
 
@@ -252,6 +253,16 @@ export async function loadExternalPlugin(
252
253
  };
253
254
  }
254
255
 
256
+ // A plugin installed where `bun` was not on PATH never got its packages.
257
+ // Not for a linked checkout: that is the author's to install.
258
+ if (!linked) {
259
+ const missing = missingPluginDependencies(pluginDir);
260
+ if (missing.length > 0) {
261
+ loaderLog.info(`Installing ${directory}'s missing dependencies: ${missing.join(", ")}`);
262
+ await installPluginDependencies(pluginDir);
263
+ }
264
+ }
265
+
255
266
  const restart = hasStaleModules(pluginDir, commit) ? { needsRestart: true } : {};
256
267
  if (!importedCommits.has(pluginDir)) importedCommits.set(pluginDir, commit);
257
268
 
@@ -3,6 +3,7 @@ import type { TimeRange } from "../../time-series/range";
3
3
  import type {
4
4
  FinancialStatement,
5
5
  Fundamentals,
6
+ NextEarnings,
6
7
  OptionsChain,
7
8
  PricePoint,
8
9
  Quote,
@@ -206,6 +207,19 @@ export function mapCloudFundamentals(fundamentals: Fundamentals | undefined): Fu
206
207
  return result;
207
208
  }
208
209
 
210
+ const EARNINGS_TIMINGS: ReadonlySet<unknown> = new Set(["bmo", "dmh", "amc"]);
211
+ const EARNINGS_TIMING_SOURCES: ReadonlySet<unknown> = new Set(["filing", "calendar", "history"]);
212
+
213
+ /** The served next report; one without a calendar date is left out. */
214
+ function mapCloudNextEarnings(value: NextEarnings | undefined): NextEarnings | undefined {
215
+ if (!value || typeof value.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value.date)) return undefined;
216
+ return {
217
+ date: value.date,
218
+ timing: EARNINGS_TIMINGS.has(value.timing) ? value.timing : null,
219
+ timingSource: EARNINGS_TIMING_SOURCES.has(value.timingSource) ? value.timingSource : null,
220
+ };
221
+ }
222
+
209
223
  /**
210
224
  * A served statement row with withdrawn values read as declared gaps, without
211
225
  * the per-issuer provenance the service can still attach.
@@ -228,6 +242,7 @@ export function mapCloudFinancials(
228
242
  quoteMetadata: financials.quoteMetadata,
229
243
  quoteContributions: financials.quoteContributions,
230
244
  profile: financials.profile,
245
+ ...(financials.nextEarnings ? { nextEarnings: mapCloudNextEarnings(financials.nextEarnings) } : {}),
231
246
  fundamentals: mapCloudFundamentals(financials.fundamentals),
232
247
  financialCurrency: financials.financialCurrency,
233
248
  statementHistory: financials.statementHistory,
@@ -57,11 +57,13 @@ function excludeNonCompanyFinancials(financials: TickerFinancials): TickerFinanc
57
57
  dividendYieldBasis: statistics.dividendYieldBasis,
58
58
  dividendYieldSource: statistics.dividendYieldSource,
59
59
  dividendRate: statistics.dividendRate,
60
+ exDividendDate: statistics.exDividendDate,
60
61
  source: statistics.source,
61
62
  fetchedAt: statistics.fetchedAt,
62
63
  stale: statistics.stale,
63
64
  } : undefined,
64
65
  profile: fund && financials.profile?.description ? { description: financials.profile.description } : undefined,
66
+ nextEarnings: undefined,
65
67
  annualStatements: [],
66
68
  quarterlyStatements: [],
67
69
  };
@@ -147,6 +147,20 @@ export interface Fundamentals {
147
147
  return3Y?: number;
148
148
  lastQuarterGrowth?: number;
149
149
  sharesOutstanding?: number;
150
+ /** Free float: shares outstanding less insider and strategic holdings. */
151
+ floatShares?: number;
152
+ beta?: number;
153
+ /** Shares sold short at the source's latest settlement. */
154
+ sharesShort?: number;
155
+ /** Shares short over float from the same observation, as a fraction (0.0127 for 1.27%). */
156
+ shortPercentOfFloat?: number;
157
+ /** Days to cover: shares short over average daily volume. */
158
+ shortRatio?: number;
159
+ /** Fractions of shares outstanding. */
160
+ insiderPercentHeld?: number;
161
+ institutionPercentHeld?: number;
162
+ /** The latest ex-dividend date (YYYY-MM-DD) while the dividend is being paid. */
163
+ exDividendDate?: string;
150
164
  }
151
165
 
152
166
  type HolderOwnerType = "institution" | "fund" | "direct" | "insider";
@@ -287,6 +301,22 @@ export interface CompanyProfile {
287
301
  description?: string;
288
302
  sector?: string;
289
303
  industry?: string;
304
+ employees?: number;
305
+ website?: string;
306
+ /** SEC Standard Industrial Classification, four digits (US filers). */
307
+ sic?: string;
308
+ sicDescription?: string;
309
+ /** Fiscal year end as MM-DD (US filers). */
310
+ fiscalYearEnd?: string;
311
+ }
312
+
313
+ /** A company's next report from the earnings calendar. */
314
+ export interface NextEarnings {
315
+ date: string;
316
+ /** Before the open, during market hours, after the close. */
317
+ timing: "bmo" | "dmh" | "amc" | null;
318
+ /** "history" when the timing is predicted from the last two reports. */
319
+ timingSource: "filing" | "calendar" | "history" | null;
290
320
  }
291
321
 
292
322
  export type IncomeStatementField = "netIncome" | "netIncomeIncludingNoncontrollingInterests" | "netIncomeCommonStockholders";
@@ -664,6 +694,7 @@ export interface TickerFinancials {
664
694
  quoteContributions?: QuoteContributionMap;
665
695
  fundamentals?: Fundamentals;
666
696
  profile?: CompanyProfile;
697
+ nextEarnings?: NextEarnings;
667
698
  annualStatements: FinancialStatement[];
668
699
  quarterlyStatements: FinancialStatement[];
669
700
  priceHistory: PricePoint[];