gloomberb 0.17.2 → 0.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +18 -8
- package/src/api-client/types.ts +3 -1
- 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/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/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- 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/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/pane.tsx +17 -13
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -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/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/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- 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 +18 -1
- 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 +32 -4
- package/src/plugins/builtin/futures/model.ts +65 -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/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/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/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 +2 -9
- 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/styles.css +6 -0
- package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
- 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 +19 -2
- 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/utils/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { HeadlessPaneContext } from "../../../types/headless";
|
|
2
|
+
import { MarketDataCoordinator, resolveEntryValue } from "../../../market-data/coordinator";
|
|
3
|
+
import { fxLegForCurrency } from "../../../market-data/coordinator/fx-legs";
|
|
4
|
+
import { resolveCurrencyUnit } from "../../../utils/currency-units";
|
|
5
|
+
import { errorMessage } from "../../../utils/errors";
|
|
6
|
+
|
|
7
|
+
/** One currency's US dollar rate, as the app's FX rates serve it: USD per unit, and when it was observed. */
|
|
8
|
+
export interface UsdRate {
|
|
9
|
+
rate: number;
|
|
10
|
+
/** The source's observation time; null when it states none. */
|
|
11
|
+
asOf: string | null;
|
|
12
|
+
/** When the rate was read, said in place of an observation time the source does not state. */
|
|
13
|
+
retrievedAt?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A rate a market cap was converted at: `to` per `from`, and the older of its two legs' observation times. */
|
|
17
|
+
export interface CapConversion {
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
rate: number;
|
|
21
|
+
asOf: string | null;
|
|
22
|
+
/** When the rate was read, for a rate whose source states no observation time. */
|
|
23
|
+
retrievedAt: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type CapRow = { symbol: string; marketCap: number | null; marketCapCurrency: string | null };
|
|
27
|
+
|
|
28
|
+
export type ConvertedCapRow<Row extends CapRow> = Row & {
|
|
29
|
+
/** The cap as the source served it, and its currency, when it was converted. */
|
|
30
|
+
marketCapNative?: number;
|
|
31
|
+
marketCapNativeCurrency?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const usable = (rate: number | undefined): rate is number => rate != null && Number.isFinite(rate) && rate > 0;
|
|
35
|
+
|
|
36
|
+
function olderOf(left: string | null, right: string | null): string | null {
|
|
37
|
+
if (!left || !right) return left ?? right;
|
|
38
|
+
return Date.parse(left) <= Date.parse(right) ? left : right;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every row's market cap in `target`, through each currency's US dollar
|
|
43
|
+
* rate (a pence cap is first made pounds). A row already in `target`, or
|
|
44
|
+
* with no cap, stays as it is. A currency with no usable rate leaves its
|
|
45
|
+
* rows in their own currency and is listed in `unconverted`, so the table
|
|
46
|
+
* never mixes a guess in.
|
|
47
|
+
*/
|
|
48
|
+
export function convertMarketCaps<Row extends CapRow>(
|
|
49
|
+
rows: readonly Row[],
|
|
50
|
+
target: string,
|
|
51
|
+
rates: ReadonlyMap<string, UsdRate>,
|
|
52
|
+
): { rows: Array<ConvertedCapRow<Row>>; conversions: CapConversion[]; unconverted: Array<{ currency: string; symbols: string[] }> } {
|
|
53
|
+
const usd = (currency: string): UsdRate | undefined => (currency === "USD" ? { rate: 1, asOf: null } : rates.get(currency));
|
|
54
|
+
const conversions = new Map<string, CapConversion>();
|
|
55
|
+
const unconverted = new Map<string, string[]>();
|
|
56
|
+
const converted = rows.map((row): ConvertedCapRow<Row> => {
|
|
57
|
+
if (row.marketCap == null || !row.marketCapCurrency || row.marketCapCurrency === target) return row;
|
|
58
|
+
const unit = resolveCurrencyUnit(row.marketCapCurrency);
|
|
59
|
+
let rate = 1;
|
|
60
|
+
if (unit.currency !== target) {
|
|
61
|
+
const from = usd(unit.currency);
|
|
62
|
+
const to = usd(target);
|
|
63
|
+
if (!from || !to || !usable(from.rate) || !usable(to.rate)) {
|
|
64
|
+
unconverted.set(row.marketCapCurrency, [...unconverted.get(row.marketCapCurrency) ?? [], row.symbol]);
|
|
65
|
+
return row;
|
|
66
|
+
}
|
|
67
|
+
rate = from.rate / to.rate;
|
|
68
|
+
if (!conversions.has(unit.currency)) {
|
|
69
|
+
conversions.set(unit.currency, {
|
|
70
|
+
from: unit.currency, to: target, rate, asOf: olderOf(from.asOf, to.asOf), retrievedAt: olderOf(from.retrievedAt ?? null, to.retrievedAt ?? null),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
...row,
|
|
76
|
+
marketCap: row.marketCap / unit.divisor * rate,
|
|
77
|
+
marketCapCurrency: target,
|
|
78
|
+
marketCapNative: row.marketCap,
|
|
79
|
+
marketCapNativeCurrency: row.marketCapCurrency,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
rows: converted,
|
|
84
|
+
conversions: [...conversions.values()],
|
|
85
|
+
unconverted: [...unconverted].map(([currency, symbols]) => ({ currency, symbols })),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A rate as a dealer quotes it: `EUR/USD 1.1612`, `USD/JPY 151.20`; a cross of two other currencies as `from/to`. */
|
|
90
|
+
export function conversionPairText({ from, to, rate }: Pick<CapConversion, "from" | "to" | "rate">): string {
|
|
91
|
+
const figure = (value: number) => value.toPrecision(5);
|
|
92
|
+
if (from !== "USD" && to !== "USD") return `${from}/${to} ${figure(rate)}`;
|
|
93
|
+
const other = to === "USD" ? from : to;
|
|
94
|
+
const usdPerOther = to === "USD" ? rate : 1 / rate;
|
|
95
|
+
const perDollar = fxLegForCurrency(other)?.invert ?? true;
|
|
96
|
+
return perDollar ? `USD/${other} ${figure(1 / usdPerOther)}` : `${other}/USD ${figure(usdPerOther)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `2026-10-13 21:00 UTC`, or `2026-10-13` for a date. */
|
|
100
|
+
function rateTimeText(asOf: string): string {
|
|
101
|
+
const time = Date.parse(asOf);
|
|
102
|
+
if (!Number.isFinite(time)) return asOf;
|
|
103
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(asOf) ? asOf : `${new Date(time).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The status line's note naming each rate and its date:
|
|
108
|
+
* `market caps in USD at EUR/USD 1.1612 (2026-10-13 21:00 UTC)`.
|
|
109
|
+
*/
|
|
110
|
+
export function conversionNote(target: string, conversions: readonly CapConversion[]): string | null {
|
|
111
|
+
if (conversions.length === 0) return null;
|
|
112
|
+
const rates = conversions.map((conversion) => `${conversionPairText(conversion)} (${conversion.asOf ? rateTimeText(conversion.asOf)
|
|
113
|
+
: conversion.retrievedAt ? `retrieved ${rateTimeText(conversion.retrievedAt)}` : "time not stated"})`);
|
|
114
|
+
return `market caps in ${target} at ${rates.join(", ")}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The US dollar rate of each currency through the app's FX path (the market
|
|
119
|
+
* data coordinator's `loadFxRate`, which converted totals read), a pence code
|
|
120
|
+
* as its pound. A currency whose rate fails is listed in `failed` with why.
|
|
121
|
+
*/
|
|
122
|
+
export async function loadFxLegs(
|
|
123
|
+
currencies: readonly string[],
|
|
124
|
+
ctx: Pick<HeadlessPaneContext, "marketData">,
|
|
125
|
+
): Promise<{ legs: Map<string, UsdRate>; failed: Array<{ currency: string; reason: string }> }> {
|
|
126
|
+
const wanted = [...new Set(currencies.map((currency) => resolveCurrencyUnit(currency).currency).filter((currency) => currency && currency !== "USD"))];
|
|
127
|
+
const legs = new Map<string, UsdRate>();
|
|
128
|
+
const failed: Array<{ currency: string; reason: string }> = [];
|
|
129
|
+
if (wanted.length === 0) return { legs, failed };
|
|
130
|
+
const coordinator = new MarketDataCoordinator(ctx.marketData);
|
|
131
|
+
try {
|
|
132
|
+
await Promise.all(wanted.map(async (currency) => {
|
|
133
|
+
try {
|
|
134
|
+
const entry = await coordinator.loadFxRate(currency);
|
|
135
|
+
const rate = resolveEntryValue(entry);
|
|
136
|
+
if (!usable(rate ?? undefined)) throw new Error(entry.error?.message ?? "no usable rate");
|
|
137
|
+
const time = (value: number | null | undefined) => (value != null && Number.isFinite(value) ? new Date(value).toISOString() : null);
|
|
138
|
+
legs.set(currency, { rate: rate!, asOf: time(entry.asOf), retrievedAt: time(entry.fetchedAt) });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
failed.push({ currency, reason: errorMessage(error) });
|
|
141
|
+
}
|
|
142
|
+
}));
|
|
143
|
+
} finally {
|
|
144
|
+
coordinator.destroy();
|
|
145
|
+
}
|
|
146
|
+
return { legs, failed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const CURRENCY_CODE = /^[A-Z]{3}$/;
|
|
150
|
+
|
|
151
|
+
/** `--currency`: a three-letter code the runtime knows, upper-cased. */
|
|
152
|
+
export function normalizeCapCurrency(value: string): string {
|
|
153
|
+
const code = value.trim().toUpperCase();
|
|
154
|
+
let known: readonly string[] = [];
|
|
155
|
+
try {
|
|
156
|
+
known = Intl.supportedValuesOf("currency");
|
|
157
|
+
} catch {
|
|
158
|
+
// An older runtime lists none; the shape check stands alone.
|
|
159
|
+
}
|
|
160
|
+
if (!CURRENCY_CODE.test(code) || (known.length > 0 && !known.includes(code))) {
|
|
161
|
+
throw new Error(`Invalid --currency value "${value}". Use a three-letter currency code such as USD or EUR.`);
|
|
162
|
+
}
|
|
163
|
+
return code;
|
|
164
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { formatPriceEarnings, NOT_MEANINGFUL, PRICE_EARNINGS_NOTICE } from "../../../utils/price-earnings";
|
|
2
|
-
import type { HeadlessPaneColumn, HeadlessPaneDefinition } from "../../../types/headless";
|
|
2
|
+
import type { HeadlessPaneColumn, HeadlessPaneContext, HeadlessPaneDefinition } from "../../../types/headless";
|
|
3
3
|
import { EXTENDED_SESSION_LABELS, type ExtendedSession } from "../../../market-data/market/status";
|
|
4
4
|
import { formatCompact, formatCurrency, formatLevelPercent, formatNumber, formatPercent, formatPercentRaw } from "../../../utils/format";
|
|
5
5
|
import { loadHeadlessFinancials, loadHeadlessSymbols } from "../shared/headless-market-data";
|
|
6
6
|
import { quoteFreshnessFields } from "../shared/report-freshness";
|
|
7
7
|
import { RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE, RELATIVE_VALUATION_STALE_QUOTE_NOTICE, relativeValuationValues } from "./relative-valuation-model";
|
|
8
8
|
import { paneSchemas } from "./headless-schema";
|
|
9
|
+
import { isRecord } from "../../../utils/guards";
|
|
10
|
+
import { resolveCurrencyUnit } from "../../../utils/currency-units";
|
|
11
|
+
import { pickPeers, peerMethodTitle, type PeerSelection } from "./relative-valuation-peers";
|
|
12
|
+
import { extraColumns, loadExtraColumns, parseExtraColumns } from "./relative-valuation-columns";
|
|
13
|
+
import { conversionNote, convertMarketCaps, loadFxLegs } from "./relative-valuation-currency";
|
|
9
14
|
|
|
10
15
|
const price = (value: unknown, row: Record<string, unknown>) => (
|
|
11
16
|
value == null || !row.currency ? "-" : formatCurrency(Number(value), String(row.currency))
|
|
@@ -46,7 +51,9 @@ const VALUATION_COLUMNS: HeadlessPaneColumn[] = [
|
|
|
46
51
|
})),
|
|
47
52
|
...[["evSales", "EV/S"]].map(([key, header]) => ({
|
|
48
53
|
key: key!, header: header!, align: "right" as const,
|
|
49
|
-
|
|
54
|
+
// A withdrawn EV says so rather than leaving a bare dash; the note under the table says why.
|
|
55
|
+
format: (value: unknown, row: Record<string, unknown>) => value != null ? formatNumber(Number(value), 1)
|
|
56
|
+
: row.evSalesUnavailable ? "EV unavailable" : "-",
|
|
50
57
|
})),
|
|
51
58
|
...[["fcfYield", "FCF Yield"], ["revenueGrowth", "Quarter Revenue YoY"], ["operatingMargin", "TTM Op Margin"]].map(([key, header]) => ({
|
|
52
59
|
key: key!, header: header!, align: "right" as const,
|
|
@@ -54,36 +61,112 @@ const VALUATION_COLUMNS: HeadlessPaneColumn[] = [
|
|
|
54
61
|
})),
|
|
55
62
|
];
|
|
56
63
|
|
|
64
|
+
/** Peers `--peers` chose, as the expansion left them in the settings or the load picked them. */
|
|
65
|
+
function peerSelectionOf(value: unknown, symbols: readonly string[]): PeerSelection | null {
|
|
66
|
+
if (!isRecord(value) || typeof value.subject !== "string" || !Array.isArray(value.peers)) return null;
|
|
67
|
+
const selection = value as unknown as PeerSelection;
|
|
68
|
+
const expected = [selection.subject, ...selection.peers];
|
|
69
|
+
return expected.length === symbols.length && expected.every((symbol, index) => symbol.toUpperCase() === symbols[index]?.toUpperCase())
|
|
70
|
+
? selection : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The peers of the one symbol `--peers` was given; several symbols are already a peer list. */
|
|
74
|
+
async function choosePeers(symbols: readonly string[], ctx: HeadlessPaneContext): Promise<PeerSelection> {
|
|
75
|
+
if (symbols.length !== 1) {
|
|
76
|
+
throw new Error(`--peers picks the peers of one symbol, such as RV ${symbols[0] ?? "DUOL"} --peers; with several symbols, leave it out.`);
|
|
77
|
+
}
|
|
78
|
+
const subject = symbols[0]!;
|
|
79
|
+
return pickPeers(subject, await loadHeadlessFinancials(ctx, subject).catch(() => null), ctx);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const PEER_SELECTION_SETTING = "peerSelection";
|
|
83
|
+
|
|
57
84
|
export const relativeValuationHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
58
85
|
...paneSchemas["relative-valuation-pane"],
|
|
86
|
+
freshness: { datesEachVenue: true },
|
|
59
87
|
shape: "rows",
|
|
60
|
-
describe: ({ symbols }) =>
|
|
88
|
+
describe: ({ symbols }, result) => {
|
|
89
|
+
const selection = peerSelectionOf(result?.metadata?.peerSelection, result?.symbols ?? symbols);
|
|
90
|
+
return selection
|
|
91
|
+
? `Relative Valuation | ${selection.subject} and ${peerMethodTitle(selection)}`
|
|
92
|
+
: `Relative Valuation | ${(result?.symbols ?? symbols).join(", ")}`;
|
|
93
|
+
},
|
|
61
94
|
columns: [...QUOTE_COLUMNS, ...VALUATION_COLUMNS],
|
|
62
|
-
async
|
|
95
|
+
async expandArgument(args, ctx) {
|
|
96
|
+
if (args.options.peers !== true) return null;
|
|
97
|
+
const selection = await choosePeers(args.symbols, ctx);
|
|
98
|
+
return {
|
|
99
|
+
argument: [selection.subject, ...selection.peers].join(", "),
|
|
100
|
+
settings: { [PEER_SELECTION_SETTING]: selection },
|
|
101
|
+
title: `RV ${selection.subject} + ${peerMethodTitle(selection, { automatic: true })}`,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
async load(args, ctx) {
|
|
105
|
+
// --peers picked in advance (fn and shot) reads the same peers; any other caller picks them here.
|
|
106
|
+
const preset = args.options.peers === true ? peerSelectionOf(ctx.settings?.[PEER_SELECTION_SETTING], args.symbols) : null;
|
|
107
|
+
const selection = args.options.peers === true ? preset ?? await choosePeers(args.symbols, ctx) : null;
|
|
108
|
+
const symbols = selection ? [selection.subject, ...selection.peers] : args.symbols;
|
|
63
109
|
const loaded = await loadHeadlessSymbols(symbols, ctx, (symbol) => loadHeadlessFinancials(ctx, symbol));
|
|
64
|
-
const
|
|
65
|
-
const unavailableSymbols = [...loaded.unavailableSymbols, ...
|
|
110
|
+
const baseRows = loaded.entries.map(({ symbol, data }) => ({ symbol, ...relativeValuationValues(data), ...quoteFreshnessFields(data?.quote) }));
|
|
111
|
+
const unavailableSymbols = [...loaded.unavailableSymbols, ...baseRows.filter((row) => ![
|
|
66
112
|
row.marketCap, row.trailingPE, row.forwardPE, row.evSales, row.fcfYield, row.revenueGrowth, row.operatingMargin,
|
|
67
113
|
].some((value) => value != null)).map(({ symbol }) => symbol)];
|
|
68
|
-
const staleSymbols =
|
|
69
|
-
const staleFundamentalsSymbols =
|
|
114
|
+
const staleSymbols = baseRows.filter((row) => row.quoteStale).map((row) => row.symbol);
|
|
115
|
+
const staleFundamentalsSymbols = baseRows.filter((row) => row.fundamentalsProvenance?.stale).map((row) => row.symbol);
|
|
116
|
+
|
|
117
|
+
// --currency: every cap in one currency, at the rates the app converts with.
|
|
118
|
+
const capCurrency = typeof args.options.currency === "string" ? args.options.currency : null;
|
|
119
|
+
const fx = capCurrency ? await loadFxLegs([capCurrency, ...baseRows.flatMap((row) => row.marketCapCurrency ?? [])], ctx) : null;
|
|
120
|
+
const converted = capCurrency && fx ? convertMarketCaps(baseRows, capCurrency, fx.legs) : null;
|
|
121
|
+
const capRows = converted?.rows ?? baseRows;
|
|
122
|
+
|
|
123
|
+
// --columns: each added column read from the path its own function uses.
|
|
124
|
+
const extraIds = parseExtraColumns(args.options.columns);
|
|
125
|
+
const financials = new Map(loaded.entries.map(({ symbol, data }) => [symbol, data] as const));
|
|
126
|
+
const extra = await loadExtraColumns(extraIds, capRows.map((row) => row.symbol), financials, ctx);
|
|
127
|
+
const rows = capRows.map((row) => ({ ...row, ...extra.fields.get(row.symbol) }));
|
|
128
|
+
|
|
70
129
|
const errors = [...loaded.errors,
|
|
71
130
|
...staleSymbols.map((symbol) => `${symbol}: ${RELATIVE_VALUATION_STALE_QUOTE_NOTICE}`),
|
|
72
131
|
...staleFundamentalsSymbols.map((symbol) => `${symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}`),
|
|
132
|
+
...extra.errors,
|
|
133
|
+
];
|
|
134
|
+
const warnings = converted?.unconverted.map(({ currency, symbols: left }) => {
|
|
135
|
+
const reason = fx?.failed.find((failure) => failure.currency === resolveCurrencyUnit(currency).currency)?.reason;
|
|
136
|
+
return `No ${currency} rate${reason ? ` (${reason})` : ""}: ${left.join(", ")} ${left.length === 1 ? "keeps its" : "keep their"} market cap in ${currency}.`;
|
|
137
|
+
}) ?? [];
|
|
138
|
+
const conversionText = capCurrency && converted ? conversionNote(capCurrency, converted.conversions) : null;
|
|
139
|
+
const notes = [
|
|
140
|
+
...selection ? [`Peers chosen automatically: ${selection.basis}. To choose your own: gloomberb fn RV ${selection.subject} <tickers>.`] : [],
|
|
141
|
+
...extra.notes,
|
|
73
142
|
];
|
|
74
143
|
// Last and Chg% are the regular session; an extended print gets a column only when a peer has one.
|
|
75
144
|
const extendedSessions = (["PRE", "POST", "OVERNIGHT"] as const).filter((session) => rows.some((row) => row.extendedSession === session));
|
|
145
|
+
const columns = [...QUOTE_COLUMNS, ...extendedSessions.map(extendedColumn), ...VALUATION_COLUMNS];
|
|
146
|
+
const added = extraIds.length > 0 ? extraColumns(extraIds) : null;
|
|
76
147
|
return {
|
|
77
148
|
rows, unavailableSymbols, errors, complete: unavailableSymbols.length === 0 && errors.length === 0,
|
|
78
|
-
...(
|
|
149
|
+
...(selection ? { symbols } : {}),
|
|
150
|
+
...(added ? { columns: [...columns, ...added.export], textColumns: [...columns, ...added.text] }
|
|
151
|
+
: extendedSessions.length > 0 ? { columns } : {}),
|
|
152
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
153
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
154
|
+
...(conversionText ? { freshness: { note: conversionText } } : {}),
|
|
79
155
|
metadata: {
|
|
80
156
|
staleSymbols,
|
|
81
157
|
staleFundamentalsSymbols,
|
|
158
|
+
...(selection ? { peerSelection: selection } : {}),
|
|
159
|
+
...(converted && capCurrency ? { marketCapConversion: { currency: capCurrency, rates: converted.conversions,
|
|
160
|
+
basis: "marketCap is converted into the asked currency at each currency's US dollar rate, the rate the app's converted totals use; marketCapNative and marketCapNativeCurrency keep the cap as served." } } : {}),
|
|
161
|
+
...(extraIds.length > 0 ? { addedColumnsBasis: "Each added column keeps its own source and as-of beside it: short interest at its FINRA settlement date, analyst targets at retrieval, the next earnings date at the calendar's retrieval, gross margin and EV/EBITDA at the screener's statement date." } : {}),
|
|
82
162
|
quoteBasis: "Stale quote fields are excluded from comparison; reportedQuote retains the rejected observation and quoteAsOf retains its source timestamp.",
|
|
83
163
|
priceBasis: "price, change and changePercent are the regular session: after the close, its close and that close's move. A pre-market, after-hours or overnight print is in extendedPrice, extendedChange and extendedChangePercent, measured from that close (extendedMark when it is a bid and ask midpoint). marketCap, the P/E multiples, evSales (through the equity part of the enterprise value) and fcfYield are measured at that regular session price.",
|
|
84
164
|
fundamentalsBasis: "Provider multiples and operating metrics retain independent fundamentalsProvenance. Retrieval time does not establish the valuation date.",
|
|
85
|
-
notices:
|
|
86
|
-
|
|
165
|
+
notices: [
|
|
166
|
+
...rows.some((row) => Object.keys(row.notMeaningful).length > 0
|
|
167
|
+
|| Object.values(row.reportedMultiples).some((value) => value != null && value <= 0)) ? [PRICE_EARNINGS_NOTICE] : [],
|
|
168
|
+
...rows.flatMap((row) => row.evSalesUnavailable ? [`${row.symbol}: ${row.evSalesUnavailable}`] : []),
|
|
169
|
+
],
|
|
87
170
|
multipleBasis: "Comparable P/E fields require a positive finite multiple over positive EPS; notMeaningful says why one is null, and reportedMultiples preserves the finite provider values.",
|
|
88
171
|
marketCapBasis: "Market caps retain their own currency and source. Fundamentals retrieval time is not a valuation date; quote timestamps do not date fallback caps.",
|
|
89
172
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { comparablePriceEarnings, notMeaningfulMultiples } from "../../../utils/price-earnings";
|
|
2
2
|
import { selectMarketCapitalization } from "../../../utils/market-capitalization";
|
|
3
|
-
import { reportedEnterpriseValue } from "../../../utils/fundamentals";
|
|
3
|
+
import { enterpriseValueReconciliationNote, enterpriseValueWithdrawn, reportedEnterpriseValue } from "../../../utils/fundamentals";
|
|
4
4
|
import { getExtendedSessionDisplay, getQuoteSessionFields } from "../../../market-data/market/status";
|
|
5
5
|
import { atRegularSessionPrice, movePriceDerivedFigures } from "../../../market-data/quotes/regular-session-valuation";
|
|
6
6
|
import type { Quote, TickerFinancials } from "../../../types/financials";
|
|
@@ -70,6 +70,9 @@ export function relativeValuationValues(snapshot: TickerFinancials | null) {
|
|
|
70
70
|
? fundamentals.enterpriseToRevenue
|
|
71
71
|
: enterpriseValue != null && fundamentals?.revenue != null && fundamentals.revenue > 0
|
|
72
72
|
? enterpriseValue / fundamentals.revenue : null,
|
|
73
|
+
/** Why EV/S is missing when the provider's EV was withdrawn, as `valuation` says it; null otherwise. */
|
|
74
|
+
evSalesUnavailable: enterpriseValueWithdrawn(fundamentals)
|
|
75
|
+
? enterpriseValueReconciliationNote(fundamentals) ?? "EV unavailable." : null,
|
|
73
76
|
fcfYield: capitalization && fundamentals?.financialCurrency === capitalization.currency
|
|
74
77
|
&& fundamentals.freeCashFlow != null && Number.isFinite(fundamentals.freeCashFlow) && capitalization.value > 0
|
|
75
78
|
? fundamentals.freeCashFlow / capitalization.value : null,
|
|
@@ -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":
|