gloomberb 0.17.2 → 0.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/api-client/chat.ts +28 -0
- package/src/api-client/errors.ts +11 -1
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +22 -9
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/api-client/types.ts +3 -1
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/commands/market.ts +60 -9
- package/src/cli/commands/search.ts +43 -2
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +21 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/dom.ts +39 -6
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +60 -4
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +41 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/index.ts +1 -1
- package/src/components/layout/header.tsx +348 -102
- package/src/components/markdown-text.tsx +40 -66
- package/src/components/market-board/index.tsx +12 -6
- package/src/components/metric-treemap/heat-surface.tsx +76 -14
- package/src/components/metric-treemap/heat-tiles.ts +5 -3
- package/src/components/metric-treemap/index.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +25 -1
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- package/src/plugins/builtin/chat/controller/connections.ts +18 -0
- package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +49 -21
- package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
- package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
- package/src/plugins/builtin/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
- package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +19 -2
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +33 -6
- package/src/plugins/builtin/futures/model.ts +69 -5
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/notes/owner.tsx +5 -4
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
- package/src/plugins/builtin/research/event-model.ts +14 -0
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/dom/styles.css +6 -0
- package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
- package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
- package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
- package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/time-series/resolve.ts +24 -5
- package/src/types/data-provider.ts +8 -0
- package/src/types/financials.ts +40 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- package/src/ui/resize-settle.ts +128 -0
- package/src/utils/calendar-date.ts +56 -0
- package/src/utils/decimal-comma.ts +31 -0
- package/src/utils/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
|
@@ -159,7 +159,10 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
159
159
|
const error = symbols.length > 0 ? peerSnapshot.error : "No tickers selected";
|
|
160
160
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
161
161
|
const [sortPreference, setSortPreference] = useState<RelativeSortPreference>(DEFAULT_RELATIVE_SORT);
|
|
162
|
-
const
|
|
162
|
+
const appBaseCurrency = useAppSelector((state) => state.config.baseCurrency);
|
|
163
|
+
// `--currency` on a capture asks for another currency than the app's.
|
|
164
|
+
const capCurrency = typeof pane?.settings?.capCurrency === "string" ? pane.settings.capCurrency : null;
|
|
165
|
+
const baseCurrency = capCurrency ?? appBaseCurrency;
|
|
163
166
|
const quoteTargets = useMemo<QuoteSubscriptionTarget[]>(() => symbols.map((peer) => ({
|
|
164
167
|
symbol: peer,
|
|
165
168
|
exchange: "",
|
|
@@ -209,8 +212,11 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
209
212
|
|
|
210
213
|
usePaneNoticeFooter({
|
|
211
214
|
registrationId: "relative-valuation-notices",
|
|
212
|
-
notices:
|
|
213
|
-
.
|
|
215
|
+
notices: [
|
|
216
|
+
...rows.filter((row) => row.fundamentalsProvenance?.stale)
|
|
217
|
+
.map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
|
|
218
|
+
...rows.flatMap((row) => row.evSalesUnavailable ? [`${row.symbol}: ${row.evSalesUnavailable}`] : []),
|
|
219
|
+
],
|
|
214
220
|
focused,
|
|
215
221
|
});
|
|
216
222
|
|
|
@@ -237,7 +243,10 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
237
243
|
case "forwardPE":
|
|
238
244
|
return { text: formatPriceEarnings(row.notMeaningful.forwardPE ? NOT_MEANINGFUL : row.reportedMultiples.forwardPE), color: colors.text };
|
|
239
245
|
case "evSales":
|
|
240
|
-
|
|
246
|
+
// A withdrawn EV says so; the footer says why.
|
|
247
|
+
return row.evSales == null && row.evSalesUnavailable
|
|
248
|
+
? { text: "EV n/a", color: colors.textDim }
|
|
249
|
+
: { text: formatNumber(row.evSales ?? undefined, 1), color: colors.text };
|
|
241
250
|
case "fcfYield":
|
|
242
251
|
return { text: formatLevelPercent(row.fcfYield ?? undefined), color: priceColor(row.fcfYield ?? 0) };
|
|
243
252
|
case "revenueGrowth":
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { ScreenDefinition, ScreenRow } from "../../../api-client/equity-screener";
|
|
2
|
+
import type { HeadlessPaneContext } from "../../../types/headless";
|
|
3
|
+
import type { TickerFinancials } from "../../../types/financials";
|
|
4
|
+
import { isUsListingExchange, parsePublicTickerKey } from "../../../utils/exchanges";
|
|
5
|
+
import { errorMessage } from "../../../utils/errors";
|
|
6
|
+
import { selectMarketCapitalization } from "../../../utils/market-capitalization";
|
|
7
|
+
import { fetchScreen } from "../equity-screener/client";
|
|
8
|
+
import { loadFxLegs } from "./relative-valuation-currency";
|
|
9
|
+
|
|
10
|
+
/** How many peers `--peers` adds at most, and the fewest it settles for before it falls back. */
|
|
11
|
+
const MOST_PEERS = 6;
|
|
12
|
+
const FEWEST_PEERS = 4;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How the peers were picked: the subject's own industry, the industry its
|
|
16
|
+
* business belongs to when its profile files it under another, a curated
|
|
17
|
+
* group's core first (the subject is one of that core) and its industry
|
|
18
|
+
* after, or a curated list when too few covered equities can be read.
|
|
19
|
+
*/
|
|
20
|
+
type PeerMethod = "industry" | "related-industry" | "curated-core" | "curated";
|
|
21
|
+
|
|
22
|
+
/** The peers `--peers` added to one symbol, and how; the pane and the report both read it. */
|
|
23
|
+
export interface PeerSelection {
|
|
24
|
+
subject: string;
|
|
25
|
+
peers: string[];
|
|
26
|
+
method: PeerMethod;
|
|
27
|
+
/** The industry the peers come from, as the screener names it. */
|
|
28
|
+
industry: string | null;
|
|
29
|
+
/** The subject's own industry, as its profile names it. */
|
|
30
|
+
subjectIndustry: string | null;
|
|
31
|
+
/** The peers taken from a curated group's core, first in `peers`; the rest came from `industry`. */
|
|
32
|
+
curatedPeers?: string[];
|
|
33
|
+
/** The curated group, as the header and the note name it (`online learning`). */
|
|
34
|
+
curatedGroup?: string;
|
|
35
|
+
/** How the peers were chosen, read after "Peers chosen automatically: ". */
|
|
36
|
+
basis: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An industry whose companies are often filed under another: a subject
|
|
41
|
+
* filed under one of `filedUnder` whose profile opens on this business takes
|
|
42
|
+
* its peers from `industry` instead. Its curated `core` are the companies
|
|
43
|
+
* that compete head on: one of them is compared with the rest of the core
|
|
44
|
+
* first and the industry after, and they join every other subject's
|
|
45
|
+
* candidates.
|
|
46
|
+
*/
|
|
47
|
+
interface RelatedIndustry {
|
|
48
|
+
/** The business, as the note names it (`its education business`). */
|
|
49
|
+
label: string;
|
|
50
|
+
industry: string;
|
|
51
|
+
filedUnder: readonly string[];
|
|
52
|
+
/** What the first sentence of the profile says when the company is in this business. */
|
|
53
|
+
business: RegExp;
|
|
54
|
+
/** The curated group the core forms, as the header and the note name it. */
|
|
55
|
+
coreLabel: string;
|
|
56
|
+
/** The curated core; also the peers used when the screener cannot answer, or covers fewer than four. */
|
|
57
|
+
core: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Kept small on purpose: an entry earns its place when an industry label hides who the competitors are. */
|
|
61
|
+
const RELATED_INDUSTRIES: readonly RelatedIndustry[] = [{
|
|
62
|
+
label: "education",
|
|
63
|
+
industry: "Education & Training Services",
|
|
64
|
+
filedUnder: ["Software - Application", "Software - Infrastructure", "Internet Content & Information"],
|
|
65
|
+
business: /\b(?:education|educational|e-?learning|online learning|language learning|learning platform|tutoring)\b/i,
|
|
66
|
+
coreLabel: "online learning",
|
|
67
|
+
core: ["DUOL", "COUR", "CHGG", "NRDY", "LRN", "UDMY"],
|
|
68
|
+
}];
|
|
69
|
+
|
|
70
|
+
/** The company `--peers` was given: its industry and business from its profile, and its market cap in US dollars. */
|
|
71
|
+
export interface PeerSubject {
|
|
72
|
+
symbol: string;
|
|
73
|
+
industry: string | null;
|
|
74
|
+
description: string | null;
|
|
75
|
+
marketCapUsd: number | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A covered equity the screener returned, with what the ranking reads. */
|
|
79
|
+
export interface PeerCandidate {
|
|
80
|
+
symbol: string;
|
|
81
|
+
marketCapUsd: number | null;
|
|
82
|
+
/** It reports revenue figures (growth or a margin): a shell or a treasury vehicle filed in an industry does not. */
|
|
83
|
+
reportsOperations: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where the subject's peers come from: its own industry, or the related one its business belongs to. */
|
|
87
|
+
interface PeerGroup {
|
|
88
|
+
industry: string;
|
|
89
|
+
related: RelatedIndustry | null;
|
|
90
|
+
/** The subject is one of the related industry's curated core, which it is compared with first. */
|
|
91
|
+
inCore: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bareSymbol(symbol: string): string {
|
|
95
|
+
return parsePublicTickerKey(symbol).symbol.toUpperCase();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A period that ends an abbreviation or an initial (`Inc.`, `N.V.`, `U.S.`), not a sentence. */
|
|
99
|
+
const ABBREVIATION_END = /(?:\b(?:inc|corp|co|ltd|plc|llc|lp|ag|se|nv|sa|no|st|mr|mrs|dr|jr|sr|vs|etc)|\b[a-z])\.$/i;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What a profile says the company does, before who it serves: its first
|
|
103
|
+
* sentence, read past the name's `Inc.` (`Duolingo, Inc. Class A Common Stock
|
|
104
|
+
* represents ownership in Duolingo, a technology company focused on...`).
|
|
105
|
+
*/
|
|
106
|
+
function firstSentence(text: string | null): string {
|
|
107
|
+
const source = text ?? "";
|
|
108
|
+
for (const match of source.matchAll(/[.!?](?=\s+[A-Z])/g)) {
|
|
109
|
+
const head = source.slice(0, match.index + 1);
|
|
110
|
+
if (!ABBREVIATION_END.test(head)) return head;
|
|
111
|
+
}
|
|
112
|
+
return source;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function peerGroupOf(subject: Pick<PeerSubject, "symbol" | "industry" | "description">): PeerGroup | null {
|
|
116
|
+
const symbol = bareSymbol(subject.symbol);
|
|
117
|
+
// A member of a curated core is placed by its symbol, whatever its profile says.
|
|
118
|
+
const core = RELATED_INDUSTRIES.find((group) => group.core.includes(symbol));
|
|
119
|
+
if (core) return { industry: core.industry, related: core, inCore: true };
|
|
120
|
+
const industry = subject.industry?.trim();
|
|
121
|
+
if (!industry) return null;
|
|
122
|
+
const related = RELATED_INDUSTRIES.find((group) => group.industry === industry
|
|
123
|
+
|| (group.filedUnder.includes(industry) && group.business.test(firstSentence(subject.description))));
|
|
124
|
+
return related ? { industry: related.industry, related, inCore: false } : { industry, related: null, inCore: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The candidates closest to the subject in market cap, measured as a ratio
|
|
129
|
+
* (half its size and twice its size are as close), at most six. A candidate
|
|
130
|
+
* that reports no revenue figures is left out, and the subject itself.
|
|
131
|
+
* Without the subject's cap the largest come first.
|
|
132
|
+
*/
|
|
133
|
+
export function rankPeerCandidates(
|
|
134
|
+
subject: Pick<PeerSubject, "symbol" | "marketCapUsd">,
|
|
135
|
+
candidates: readonly PeerCandidate[],
|
|
136
|
+
{ limit = MOST_PEERS, exclude = [] }: { limit?: number; exclude?: readonly string[] } = {},
|
|
137
|
+
): string[] {
|
|
138
|
+
const seen = new Set([bareSymbol(subject.symbol), ...exclude.map(bareSymbol)]);
|
|
139
|
+
const usable = candidates.filter((candidate) => {
|
|
140
|
+
const key = bareSymbol(candidate.symbol);
|
|
141
|
+
if (!candidate.reportsOperations || seen.has(key)) return false;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
const base = subject.marketCapUsd != null && subject.marketCapUsd > 0 ? subject.marketCapUsd : null;
|
|
146
|
+
const distance = (cap: number | null) => (base && cap != null && cap > 0 ? Math.abs(Math.log(cap / base)) : Number.POSITIVE_INFINITY);
|
|
147
|
+
return usable
|
|
148
|
+
.sort((left, right) => distance(left.marketCapUsd) - distance(right.marketCapUsd)
|
|
149
|
+
|| (right.marketCapUsd ?? 0) - (left.marketCapUsd ?? 0)
|
|
150
|
+
|| left.symbol.localeCompare(right.symbol))
|
|
151
|
+
.slice(0, limit)
|
|
152
|
+
.map((candidate) => candidate.symbol);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `A`, `A and B`, `A, B and C`. */
|
|
156
|
+
function listText(items: readonly string[]): string {
|
|
157
|
+
return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The covered equities the screener returned for a subject's group: its industry's, and its curated core's. */
|
|
161
|
+
export interface PeerCandidates {
|
|
162
|
+
industry: readonly PeerCandidate[];
|
|
163
|
+
core: readonly PeerCandidate[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The peers of the subject, from the covered equities of its group. One of a
|
|
168
|
+
* curated core is compared with the rest of that core first, closest in
|
|
169
|
+
* market cap, and the industry's closest fill the table to six. Any other
|
|
170
|
+
* subject takes the closest of its own industry, or of the related industry
|
|
171
|
+
* its business belongs to with that core among the candidates. Fewer than
|
|
172
|
+
* four, or no candidates at all (`candidates` null: the screener did not
|
|
173
|
+
* answer), falls back to the related industry's curated core when it has
|
|
174
|
+
* one; otherwise what was found, or null when nothing was.
|
|
175
|
+
*/
|
|
176
|
+
export function selectPeers(subject: PeerSubject, group: PeerGroup, candidates: PeerCandidates | null): PeerSelection | null {
|
|
177
|
+
const symbol = subject.symbol;
|
|
178
|
+
const related = group.related;
|
|
179
|
+
const curatedCore = group.inCore && related ? (candidates ? rankPeerCandidates(subject, candidates.core) : []) : [];
|
|
180
|
+
const fill = candidates
|
|
181
|
+
? group.inCore
|
|
182
|
+
? rankPeerCandidates(subject, candidates.industry, { limit: MOST_PEERS - curatedCore.length, exclude: curatedCore })
|
|
183
|
+
: rankPeerCandidates(subject, [...candidates.industry, ...related ? candidates.core : []])
|
|
184
|
+
: [];
|
|
185
|
+
const peers = [...curatedCore, ...fill];
|
|
186
|
+
if (peers.length < FEWEST_PEERS && related) {
|
|
187
|
+
const curated = related.core.filter((peer) => peer !== bareSymbol(symbol)).slice(0, MOST_PEERS);
|
|
188
|
+
return {
|
|
189
|
+
subject: symbol, peers: curated, method: "curated", industry: related.industry, subjectIndustry: subject.industry,
|
|
190
|
+
curatedPeers: curated, curatedGroup: related.coreLabel,
|
|
191
|
+
basis: `the ${related.coreLabel} curated list (${listText(curated)}), as ${candidates ? `fewer than ${FEWEST_PEERS}` : "no"} covered equities in ${related.industry} could be read`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (peers.length === 0) return null;
|
|
195
|
+
// `the 6 covered equities in X closest to DUOL in market cap`, the fill after a core naming its own.
|
|
196
|
+
const closest = (names: readonly string[]) => {
|
|
197
|
+
const count = names.length === 1 ? "the covered equity" : `the ${names.length} covered equities`;
|
|
198
|
+
return `${names === peers ? "" : `${listText(names)}, `}${count} in ${group.industry} closest to ${symbol} in market cap`;
|
|
199
|
+
};
|
|
200
|
+
if (related && group.inCore && curatedCore.length > 0) {
|
|
201
|
+
return {
|
|
202
|
+
subject: symbol, peers, method: "curated-core", industry: group.industry, subjectIndustry: subject.industry,
|
|
203
|
+
curatedPeers: curatedCore, curatedGroup: related.coreLabel,
|
|
204
|
+
basis: `${listText(curatedCore)} from the ${related.coreLabel} curated core${fill.length > 0 ? `; ${closest(fill)}` : ""}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (related && group.industry !== subject.industry) {
|
|
208
|
+
return {
|
|
209
|
+
subject: symbol, peers, method: "related-industry", industry: group.industry, subjectIndustry: subject.industry,
|
|
210
|
+
basis: `${closest(peers)}, where its ${related.label} business sits though its profile files it under ${subject.industry ?? "another industry"}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { subject: symbol, peers, method: "industry", industry: group.industry, subjectIndustry: subject.industry, basis: closest(peers) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The header's words for the peers: `peers by industry: Airlines`; a pane
|
|
218
|
+
* title, which has no note under it, says they were chosen for the reader
|
|
219
|
+
* (`auto peers by industry: Airlines`).
|
|
220
|
+
*/
|
|
221
|
+
export function peerMethodTitle(selection: PeerSelection, { automatic = false }: { automatic?: boolean } = {}): string {
|
|
222
|
+
const peers = automatic ? "auto peers" : "peers";
|
|
223
|
+
switch (selection.method) {
|
|
224
|
+
case "industry":
|
|
225
|
+
return `${peers} by industry: ${selection.industry}`;
|
|
226
|
+
case "related-industry":
|
|
227
|
+
return `${peers} by related industry: ${selection.industry}`;
|
|
228
|
+
case "curated-core":
|
|
229
|
+
return selection.peers.length > (selection.curatedPeers?.length ?? 0)
|
|
230
|
+
? `${peers}: ${selection.curatedGroup} curated core, then ${selection.industry}`
|
|
231
|
+
: `${peers}: ${selection.curatedGroup} curated core`;
|
|
232
|
+
case "curated":
|
|
233
|
+
return `${peers} from a curated list`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function screenSymbol(row: ScreenRow): string {
|
|
238
|
+
return isUsListingExchange(row.exchange) ? row.symbol : `${row.symbol}:${row.exchange}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function candidateOf(row: ScreenRow): PeerCandidate {
|
|
242
|
+
const metric = (field: keyof ScreenRow["metrics"]) => row.metrics[field]?.value ?? null;
|
|
243
|
+
return {
|
|
244
|
+
symbol: screenSymbol(row),
|
|
245
|
+
marketCapUsd: metric("marketCap"),
|
|
246
|
+
reportsOperations: metric("revenueGrowthPercent") != null || metric("grossMarginPercent") != null
|
|
247
|
+
|| metric("operatingMarginPercent") != null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function screenRows(criterion: ScreenDefinition["criteria"][number], ctx: HeadlessPaneContext): Promise<ScreenRow[]> {
|
|
252
|
+
const definition: ScreenDefinition = { version: 1, criteria: [criterion], sort: { field: "marketCap", direction: "desc" }, currency: "USD" };
|
|
253
|
+
return (await fetchScreen(definition, null, ctx.signal, ctx.apiClient)).rows;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The subject's market cap in US dollars, through the app's FX rates when it is quoted in another currency. */
|
|
257
|
+
async function subjectCapUsd(financials: TickerFinancials | null, ctx: HeadlessPaneContext): Promise<number | null> {
|
|
258
|
+
const cap = selectMarketCapitalization(financials?.quote, financials?.fundamentals);
|
|
259
|
+
if (!cap) return null;
|
|
260
|
+
if (cap.currency === "USD") return cap.value;
|
|
261
|
+
const { legs } = await loadFxLegs([cap.currency], ctx);
|
|
262
|
+
const rate = legs.get(cap.currency)?.rate;
|
|
263
|
+
return rate != null ? cap.value * rate : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Picks the peers of `symbol` for `--peers`: its group from its symbol or
|
|
268
|
+
* profile, the group's covered equities from the screener (its industry, and
|
|
269
|
+
* a related industry's curated core), ranked as `selectPeers` says. Throws
|
|
270
|
+
* when nothing places the subject, or nothing can be found, saying how to
|
|
271
|
+
* list peers instead.
|
|
272
|
+
*/
|
|
273
|
+
export async function pickPeers(symbol: string, financials: TickerFinancials | null, ctx: HeadlessPaneContext): Promise<PeerSelection> {
|
|
274
|
+
const subject: PeerSubject = {
|
|
275
|
+
symbol,
|
|
276
|
+
industry: financials?.profile?.industry?.trim() || null,
|
|
277
|
+
description: financials?.profile?.description ?? null,
|
|
278
|
+
marketCapUsd: null,
|
|
279
|
+
};
|
|
280
|
+
const listYourOwn = `List them yourself: gloomberb fn RV ${symbol} <tickers>.`;
|
|
281
|
+
const group = peerGroupOf(subject);
|
|
282
|
+
if (!group) throw new Error(`${symbol}'s profile names no industry, so --peers cannot pick its peers. ${listYourOwn}`);
|
|
283
|
+
let candidates: PeerCandidates | null = null;
|
|
284
|
+
let failure: string | null = null;
|
|
285
|
+
try {
|
|
286
|
+
const [capUsd, industryRows, coreRows] = await Promise.all([
|
|
287
|
+
subjectCapUsd(financials, ctx).catch(() => null),
|
|
288
|
+
screenRows({ field: "industry", op: "in", value: [group.industry] }, ctx),
|
|
289
|
+
group.related ? screenRows({ field: "symbol", op: "in", value: [...group.related.core] }, ctx) : Promise.resolve([]),
|
|
290
|
+
]);
|
|
291
|
+
subject.marketCapUsd = capUsd;
|
|
292
|
+
candidates = { industry: industryRows.map(candidateOf), core: coreRows.map(candidateOf) };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
ctx.signal.throwIfAborted();
|
|
295
|
+
failure = errorMessage(error);
|
|
296
|
+
}
|
|
297
|
+
const selection = selectPeers(subject, group, candidates);
|
|
298
|
+
if (selection) return selection;
|
|
299
|
+
throw new Error(failure
|
|
300
|
+
? `--peers could not read the covered equities in ${group.industry}: ${failure} ${listYourOwn}`
|
|
301
|
+
: `No other covered equity is in ${group.industry}, so --peers found no peers for ${symbol}. ${listYourOwn}`);
|
|
302
|
+
}
|
|
@@ -52,6 +52,17 @@ function documentKey(docType: CloudSearchDocType, sourceId: string): string {
|
|
|
52
52
|
return `${docType}:${sourceId}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function cachedSearchDocument(
|
|
56
|
+
docType: CloudSearchDocType,
|
|
57
|
+
sourceId: string,
|
|
58
|
+
): CloudSearchDocument | null {
|
|
59
|
+
return persistence?.getResource<CloudSearchDocument>(DOCUMENT_KIND, documentKey(docType, sourceId), {
|
|
60
|
+
sourceKey: CACHE_SOURCE,
|
|
61
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
62
|
+
allowExpired: true,
|
|
63
|
+
})?.value ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
export async function loadSearchDocument(
|
|
56
67
|
docType: CloudSearchDocType,
|
|
57
68
|
sourceId: string,
|
|
@@ -41,6 +41,7 @@ import { usePlanAccess } from "../../../api-client/plan-access";
|
|
|
41
41
|
import {
|
|
42
42
|
createSavedSearch,
|
|
43
43
|
deleteSavedSearch,
|
|
44
|
+
cachedSearchDocument,
|
|
44
45
|
isAbortError,
|
|
45
46
|
loadSavedSearches,
|
|
46
47
|
loadSearchDocument,
|
|
@@ -147,6 +148,14 @@ export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps
|
|
|
147
148
|
[hits, openHitId],
|
|
148
149
|
);
|
|
149
150
|
const openHit = openHitIndex >= 0 ? hits[openHitIndex]! : null;
|
|
151
|
+
// The document the cache holds paints at once; another document never
|
|
152
|
+
// shows under this hit's title while its own loads.
|
|
153
|
+
const [seededDocumentKey, setSeededDocumentKey] = useState<string | null>(null);
|
|
154
|
+
const openDocumentKey = openHit ? hitDocumentKey(openHit) : null;
|
|
155
|
+
if (seededDocumentKey !== openDocumentKey) {
|
|
156
|
+
setSeededDocumentKey(openDocumentKey);
|
|
157
|
+
setDocument(openHit ? cachedSearchDocument(openHit.docType, openHit.sourceId) : null);
|
|
158
|
+
}
|
|
150
159
|
|
|
151
160
|
const focusField = useCallback((field: Exclude<ActiveField, null>) => {
|
|
152
161
|
setActiveField(field);
|
|
@@ -165,7 +174,6 @@ export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps
|
|
|
165
174
|
const controller = new AbortController();
|
|
166
175
|
setDocumentLoading(true);
|
|
167
176
|
setDocumentFailure(null);
|
|
168
|
-
setDocument(null);
|
|
169
177
|
void loadSearchDocument(openHit.docType, openHit.sourceId, controller.signal)
|
|
170
178
|
.then((loaded) => {
|
|
171
179
|
if (controller.signal.aborted) return;
|
|
@@ -173,7 +181,10 @@ export function ResearchSearchPane({ focused, paneId, width, height }: PaneProps
|
|
|
173
181
|
})
|
|
174
182
|
.catch((error: unknown) => {
|
|
175
183
|
if (controller.signal.aborted || isAbortError(error)) return;
|
|
176
|
-
|
|
184
|
+
const status = statusOf(error);
|
|
185
|
+
// Losing entitlement re-gates the document, not the copy the cache seeded.
|
|
186
|
+
if (status === 401 || status === 402 || status === 403) setDocument(null);
|
|
187
|
+
setDocumentFailure({ message: errorMessage(error), status });
|
|
177
188
|
})
|
|
178
189
|
.finally(() => {
|
|
179
190
|
if (!controller.signal.aborted) setDocumentLoading(false);
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
DataTableView,
|
|
5
5
|
PaneStatusBody,
|
|
6
6
|
QueryBar,
|
|
7
|
-
Tabs,
|
|
8
7
|
type DataTableCell,
|
|
9
8
|
type DataTableColumn,
|
|
10
9
|
type DataTableKeyEvent,
|
|
@@ -125,9 +124,7 @@ function HiloPane({ focused, width, height }: PaneProps) {
|
|
|
125
124
|
const showBars = !!feed.payload && height >= BARS_MIN_HEIGHT;
|
|
126
125
|
// One cell of gutter keeps the two cursors from reading as a single wide row.
|
|
127
126
|
const tableWidth = split ? Math.max(12, Math.floor((width - 1) / 2)) : Math.max(12, width);
|
|
128
|
-
|
|
129
|
-
const sideStripRows = split ? 0 : 1;
|
|
130
|
-
const tableHeight = Math.max(2, height - QUERY_BAR_HEIGHT - (showBars ? BARS_HEIGHT : 0) - sideStripRows);
|
|
127
|
+
const tableHeight = Math.max(2, height - QUERY_BAR_HEIGHT - (showBars ? BARS_HEIGHT : 0));
|
|
131
128
|
const columns = useMemo(() => ({ lows: buildColumns(tableWidth, "lows"), highs: buildColumns(tableWidth, "highs") }), [tableWidth]);
|
|
132
129
|
|
|
133
130
|
const handleSelect = useCallback((side: Side, row: ScannerHiloExtreme, index: number) => {
|
|
@@ -184,21 +181,14 @@ function HiloPane({ focused, width, height }: PaneProps) {
|
|
|
184
181
|
options: MIN_PRICE_OPTIONS, onChange: setMinPrice },
|
|
185
182
|
{ id: "sort", label: "Sort", inline: true, value: sort, options: SORT_OPTIONS, onChange: setSort },
|
|
186
183
|
]}
|
|
184
|
+
view={split ? undefined : {
|
|
185
|
+
value: activeSide,
|
|
186
|
+
options: SIDE_TABS,
|
|
187
|
+
onChange: (value) => setActiveSide(value),
|
|
188
|
+
focused,
|
|
189
|
+
}}
|
|
187
190
|
/>
|
|
188
191
|
{showBars && <HiloBars windows={feed.payload?.windows} width={width} />}
|
|
189
|
-
{!split && (
|
|
190
|
-
// Left and right (and h/l) switch sides, as they do between the split tables.
|
|
191
|
-
<Box height={1} paddingX={1} flexShrink={0}>
|
|
192
|
-
<Tabs
|
|
193
|
-
tabs={SIDE_TABS}
|
|
194
|
-
activeValue={activeSide}
|
|
195
|
-
onSelect={(value) => setActiveSide(value as Side)}
|
|
196
|
-
compact
|
|
197
|
-
dense
|
|
198
|
-
focused={focused}
|
|
199
|
-
/>
|
|
200
|
-
</Box>
|
|
201
|
-
)}
|
|
202
192
|
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
203
193
|
{split ? (
|
|
204
194
|
<>
|
|
@@ -22,6 +22,29 @@ export const SEASONALITY_HISTORY_RESOLUTION: Record<SeasonalityCadence, ManualCh
|
|
|
22
22
|
/** Daily history is served to five years; a longer request can come back as weekly bars. */
|
|
23
23
|
const BUFFER_RANGE = { monthly: "ALL", daily: "5Y" } as const;
|
|
24
24
|
|
|
25
|
+
export function cachedSeasonalityHistory(
|
|
26
|
+
request: { instrument: InstrumentRef; cadence?: SeasonalityCadence },
|
|
27
|
+
): SeasonalityHistory | null {
|
|
28
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
29
|
+
if (!coordinator) return null;
|
|
30
|
+
const cadence = request.cadence ?? "monthly";
|
|
31
|
+
const entry = coordinator.getChartEntry({
|
|
32
|
+
instrument: request.instrument,
|
|
33
|
+
bufferRange: BUFFER_RANGE[cadence],
|
|
34
|
+
granularity: "resolution",
|
|
35
|
+
resolution: SEASONALITY_HISTORY_RESOLUTION[cadence],
|
|
36
|
+
});
|
|
37
|
+
const history = resolveEntryValue(entry);
|
|
38
|
+
if (!history?.length) return null;
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
return {
|
|
41
|
+
history,
|
|
42
|
+
stale: !!entry.error || (entry.staleAt != null && entry.staleAt <= now),
|
|
43
|
+
error: entry.error?.message ?? null,
|
|
44
|
+
fetchedAt: entry.fetchedAt ?? now,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
/**
|
|
26
49
|
* Every month on record: the lookback is cut in the model, so changing it never
|
|
27
50
|
* refetches. Monthly bars, because daily history is only served to 5Y where
|
|
@@ -14,7 +14,7 @@ import { useThemeColors } from "../../../theme/theme-context";
|
|
|
14
14
|
import type { ResolvedSeries } from "../../../time-series/types";
|
|
15
15
|
import type { PaneProps } from "../../../types/plugin";
|
|
16
16
|
import { Box } from "../../../ui";
|
|
17
|
-
import { loadSeasonalityHistory } from "./client";
|
|
17
|
+
import { cachedSeasonalityHistory, loadSeasonalityHistory } from "./client";
|
|
18
18
|
import { type DailyReturnStat, MONTH_LABELS, OVERLAY_YEAR, projectSeasonality, projectWeekdays, type SeasonalityModel,
|
|
19
19
|
TURN_OF_MONTH_LABELS, type WeekdayModel } from "./model";
|
|
20
20
|
|
|
@@ -156,7 +156,9 @@ export function SeasonalityPane({ width, height, focused }: PaneProps) {
|
|
|
156
156
|
if (!snapshot.history.length && snapshot.error) throw new Error(snapshot.error);
|
|
157
157
|
return snapshot;
|
|
158
158
|
}, [instrumentKey, cadence]);
|
|
159
|
-
const history = useAsyncResource(instrument ? loader : null
|
|
159
|
+
const history = useAsyncResource(instrument ? loader : null, {
|
|
160
|
+
initialData: () => instrument ? cachedSeasonalityHistory({ instrument, cadence }) : null,
|
|
161
|
+
});
|
|
160
162
|
useEffect(() => () => controller.current?.abort(), [loader]);
|
|
161
163
|
useAutoRefresh(history.updatedAt, history.load);
|
|
162
164
|
usePaneRefreshKey(() => { void history.reload(); }, { focused });
|
|
@@ -323,11 +323,15 @@ function EtfBoard({ collectionId, focused, width, height, rootBefore }: EtfBoard
|
|
|
323
323
|
text: row.loading && row.return1Y === null ? "…" : row.return1Y !== null ? formatPercentRaw(shownPercent(row.return1Y)) : "—",
|
|
324
324
|
color: row.return1Y !== null ? priceColor(shownPercent(row.return1Y)) : colors.textDim,
|
|
325
325
|
};
|
|
326
|
-
case "bar":
|
|
326
|
+
case "bar": {
|
|
327
|
+
const signed = row.changePercent !== null ? formatPercentRaw(shownPercent(row.changePercent)) : "—";
|
|
327
328
|
return {
|
|
328
|
-
text:
|
|
329
|
-
content: <
|
|
329
|
+
text: signed,
|
|
330
|
+
content: <Box flexDirection="row" width={column.width} overflow="hidden" aria-label={signed}>
|
|
331
|
+
<SectorMoveBar changePercent={row.changePercent} width={column.width} />
|
|
332
|
+
</Box>,
|
|
330
333
|
};
|
|
334
|
+
}
|
|
331
335
|
}
|
|
332
336
|
}, []);
|
|
333
337
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { HeadlessPaneFreshness } from "../../../types/headless";
|
|
2
|
-
import type { Quote } from "../../../types/financials";
|
|
2
|
+
import type { Quote, QuoteIndexSession } from "../../../types/financials";
|
|
3
3
|
import { isQuoteStaleForCurrentSession } from "../../../market-data/quotes/freshness";
|
|
4
|
-
import { delayedFeedLagMs } from "../../../market-data/market/freshness";
|
|
4
|
+
import { delayedFeedLagMs, readIndexSession } from "../../../market-data/market/freshness";
|
|
5
5
|
import { apiClient } from "../../../api-client";
|
|
6
6
|
import { isPerpetualExchange } from "../../../tickers/perpetual-listing";
|
|
7
7
|
import { CLOUD_NEWS_DELAY_HOURS, hasProAccess } from "../../../api-client/plan-rules";
|
|
@@ -56,6 +56,8 @@ interface QuoteFreshnessFields {
|
|
|
56
56
|
/** The venue whose sessions the quote follows, so the report can say whether its market is open. */
|
|
57
57
|
sessionExchange?: string;
|
|
58
58
|
marketState?: Quote["marketState"];
|
|
59
|
+
/** An index's own regular sessions, for the open and the close its publisher's name does not place. */
|
|
60
|
+
sessionCalendar?: QuoteIndexSession;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/**
|
|
@@ -71,12 +73,14 @@ export function quoteFreshnessFields(
|
|
|
71
73
|
const exchange = quote.listingExchangeName || quote.exchangeName;
|
|
72
74
|
// A perpetual's venue is read about once a minute, not held back by a feed lag.
|
|
73
75
|
const delayed = quote.dataSource === "delayed" && quote.providerId === CLOUD_QUOTE_PROVIDER_ID && !isPerpetualExchange(exchange);
|
|
76
|
+
const sessionCalendar = readIndexSession(quote.indexSession);
|
|
74
77
|
return {
|
|
75
78
|
...(quote.dataSource === "live" || quote.dataSource === "delayed" ? { dataSource: quote.dataSource } : {}),
|
|
76
79
|
stale: isQuoteStaleForCurrentSession(quote, now),
|
|
77
80
|
...(delayed ? { delayMinutes: delayedFeedLagMs(exchange) / 60_000 } : {}),
|
|
78
81
|
...(exchange ? { sessionExchange: exchange } : {}),
|
|
79
82
|
...(quote.marketState ? { marketState: quote.marketState } : {}),
|
|
83
|
+
...(sessionCalendar ? { sessionCalendar } : {}),
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
|
|
@@ -5,13 +5,15 @@ import type {
|
|
|
5
5
|
HeadlessPaneLoadArgs,
|
|
6
6
|
} from "../../../types/plugin";
|
|
7
7
|
import { formatCompact, formatNumber } from "../../../utils/format";
|
|
8
|
-
import {
|
|
8
|
+
import type { ShortInterestResult } from "./client";
|
|
9
9
|
import {
|
|
10
10
|
buildRows,
|
|
11
11
|
sortRows,
|
|
12
12
|
type SortPreference,
|
|
13
13
|
} from "./model";
|
|
14
|
+
import { shortInterestFreshness } from "./schedule";
|
|
14
15
|
import type { ShortInterestRecord } from "./types";
|
|
16
|
+
import { loadShortInterestWithFloat } from "./watch-client";
|
|
15
17
|
|
|
16
18
|
const SHORT_INTEREST_COLUMNS: HeadlessPaneColumn[] = [
|
|
17
19
|
{ key: "settlementDate", header: "Date" },
|
|
@@ -23,7 +25,7 @@ const SHORT_INTEREST_COLUMNS: HeadlessPaneColumn[] = [
|
|
|
23
25
|
},
|
|
24
26
|
{
|
|
25
27
|
key: "daysToCover",
|
|
26
|
-
header: "Days to
|
|
28
|
+
header: "Days to cover",
|
|
27
29
|
align: "right",
|
|
28
30
|
format: (value) => value == null ? "-" : formatNumber(Number(value), 2),
|
|
29
31
|
},
|
|
@@ -50,7 +52,7 @@ export interface ShortInterestHeadlessDependencies {
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
const defaultDependencies: ShortInterestHeadlessDependencies = {
|
|
53
|
-
loadRecords: (symbol, _args, ctx) =>
|
|
55
|
+
loadRecords: (symbol, _args, ctx) => loadShortInterestWithFloat(symbol, { marketData: ctx.marketData, client: ctx.apiClient }),
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
export function createShortInterestHeadless(
|
|
@@ -65,6 +67,12 @@ export function createShortInterestHeadless(
|
|
|
65
67
|
placeholder: "ticker",
|
|
66
68
|
description: "US equity ticker.",
|
|
67
69
|
},
|
|
70
|
+
discovery: {
|
|
71
|
+
limitations: [
|
|
72
|
+
"Borrow fee, utilization and locate availability are not available: FINRA short interest counts shares short, not what they cost to borrow.",
|
|
73
|
+
"Percent of float is the latest settlement's shares short over the current float; earlier settlements stay blank.",
|
|
74
|
+
],
|
|
75
|
+
},
|
|
68
76
|
usListingsOnly: "FINRA short interest",
|
|
69
77
|
options: [
|
|
70
78
|
{
|
|
@@ -107,8 +115,10 @@ export function createShortInterestHeadless(
|
|
|
107
115
|
averageDailyVolume: row.record.averageDailyVolume,
|
|
108
116
|
shortPercentFloat: row.record.shortPercentFloat,
|
|
109
117
|
}));
|
|
118
|
+
const freshness = shortInterestFreshness(rows.map((row) => row.settlementDate).sort().at(-1));
|
|
110
119
|
return {
|
|
111
120
|
rows,
|
|
121
|
+
...(freshness ? { freshness } : {}),
|
|
112
122
|
...(latestSettlements ? {
|
|
113
123
|
complete: false,
|
|
114
124
|
errors: [cloudSessionRequired
|
|
@@ -85,7 +85,7 @@ export const shortInterestModule: PluginModule = {
|
|
|
85
85
|
label: "Short Interest",
|
|
86
86
|
// The current snapshot only carries the current and prior settlement dates.
|
|
87
87
|
description: "Bi-monthly short interest settlements from FINRA with days to cover and average daily volume. Daily volume is the other tab.",
|
|
88
|
-
keywords: ["short", "interest", "si", "shorts", "
|
|
88
|
+
keywords: ["short", "interest", "si", "shorts", "days", "cover"],
|
|
89
89
|
shortcut: "SI",
|
|
90
90
|
}), "interest"),
|
|
91
91
|
headless: shortInterestHeadless,
|