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 +1 -1
- package/src/plugins/auto-update-node.ts +2 -1
- package/src/plugins/builtin/correlation/colors.ts +17 -7
- package/src/plugins/builtin/correlation/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/components.tsx +94 -15
- package/src/plugins/builtin/ticker-detail/overview/model.ts +108 -2
- package/src/plugins/builtin/ticker-detail/overview/pane.tsx +12 -0
- package/src/plugins/builtin/ticker-detail/overview/types.ts +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +27 -34
- package/src/plugins/builtin/ticker-detail/pane.tsx +15 -6
- package/src/plugins/builtin/ticker-detail/research-tab-navigation.tsx +14 -0
- package/src/plugins/dependencies.ts +56 -0
- package/src/plugins/host-link.ts +28 -9
- package/src/plugins/installer.ts +5 -1
- package/src/plugins/loader.ts +11 -0
- package/src/sources/gloomberb-cloud/normalizers.ts +15 -0
- package/src/sources/provider-router/financials.ts +2 -0
- package/src/types/financials.ts +31 -0
package/package.json
CHANGED
|
@@ -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: () =>
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
60
|
+
export function resolveCorrelationHeatmapCellColors(
|
|
61
|
+
correlation: number | null,
|
|
62
|
+
options: { diagonal?: boolean } = {},
|
|
63
|
+
): CorrelationHeatmapCellColors {
|
|
56
64
|
const baseBackground = heatmapBaseBackground();
|
|
57
65
|
|
|
58
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
194
|
-
|
|
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 =
|
|
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
|
-
|
|
224
|
-
<
|
|
225
|
-
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
-
<
|
|
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
|
-
{
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
<
|
|
299
|
-
|
|
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
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
}
|
package/src/plugins/host-link.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync,
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
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
|
/**
|
package/src/plugins/installer.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/plugins/loader.ts
CHANGED
|
@@ -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
|
};
|
package/src/types/financials.ts
CHANGED
|
@@ -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[];
|