gloomberb 0.19.0 → 0.19.2
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +2 -1
- package/patches/electrobun@1.18.1.patch +23 -1
- package/src/api-client/app-attention.ts +3 -0
- package/src/api-client/beneficial-owners.ts +3 -0
- package/src/api-client/company-kpis.ts +3 -0
- package/src/api-client/data.ts +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/api-client/hiring.ts +3 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/ipo.ts +24 -1
- package/src/api-client/mna.ts +3 -0
- package/src/api-client/paths.ts +9 -1
- package/src/api-client/roadmap.ts +85 -0
- package/src/api-client/types.ts +12 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/architecture/core-builtin-allowlist.json +2 -2
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +44 -16
- package/src/cli/commands/news-search.ts +22 -0
- package/src/cli/commands/overview.ts +84 -19
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/commands/ticker.ts +32 -2
- package/src/cli/context.ts +1 -0
- package/src/cli/errors.ts +17 -2
- package/src/cli/glossary.ts +8 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/index-roots.ts +0 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +74 -11
- package/src/cli/pane-functions/capabilities.ts +1 -0
- package/src/cli/pane-functions/discovery.ts +21 -1
- package/src/cli/pane-functions/freshness.ts +7 -1
- package/src/cli/pane-functions/function-help.ts +7 -0
- package/src/cli/pane-functions/index.ts +6 -3
- package/src/cli/quote-answer.ts +30 -7
- package/src/cli/saved-copy-persistence.ts +88 -0
- package/src/components/command-bar/list/model.ts +5 -0
- package/src/components/command-bar/routes/root/runtime.ts +29 -8
- package/src/components/command-bar/selection-runtime.ts +64 -32
- package/src/components/command-bar/surface/index.tsx +2 -0
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/capture-stamp.ts +2 -2
- package/src/components/layout/pane/footer/index.tsx +23 -6
- package/src/components/layout/pane/footer/model.ts +116 -16
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +37 -3
- package/src/components/layout/status-widget-space.ts +9 -0
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ticker/list-table-view.tsx +4 -20
- package/src/components/ui/display.tsx +29 -3
- package/src/components/ui/icon.tsx +12 -3
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +20 -3
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/saved-copies.ts +8 -0
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/docs/snapshot.json +32 -5
- package/src/i18n/es.ts +2 -4
- package/src/i18n/ja.ts +2 -4
- package/src/i18n/ko.ts +2 -4
- package/src/i18n/zh-cn.ts +2 -4
- package/src/i18n/zh-tw.ts +2 -4
- package/src/market-data/depositary-pair.ts +25 -1
- package/src/market-data/quotes/metadata.ts +7 -0
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/news/aggregator.ts +7 -1
- package/src/news/types.ts +6 -0
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
- package/src/plugins/builtin/alerts/cli.ts +34 -11
- package/src/plugins/builtin/alerts/event-rows.ts +24 -3
- package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
- package/src/plugins/builtin/alerts/headless.ts +10 -6
- package/src/plugins/builtin/cds/client.ts +142 -30
- package/src/plugins/builtin/cds/headless.ts +35 -8
- package/src/plugins/builtin/cds/model.ts +145 -18
- package/src/plugins/builtin/cds/pane.tsx +16 -5
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/index.tsx +4 -5
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +5 -1
- package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
- package/src/plugins/builtin/chat/controller/state.ts +25 -2
- package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
- package/src/plugins/builtin/chat/controller/utils.ts +7 -2
- package/src/plugins/builtin/chat/status-preview.ts +79 -0
- package/src/plugins/builtin/chat/status-widget.tsx +147 -10
- package/src/plugins/builtin/chat/unread-badge.ts +51 -10
- package/src/plugins/builtin/clinical-trials/client.ts +49 -4
- package/src/plugins/builtin/clinical-trials/model.ts +86 -22
- package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
- package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
- package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
- package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/company-attention/headless.ts +2 -1
- package/src/plugins/builtin/company-attention/pane.tsx +3 -0
- package/src/plugins/builtin/company-kpis/headless.ts +8 -3
- package/src/plugins/builtin/company-kpis/model.ts +73 -5
- package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
- package/src/plugins/builtin/composite-plugins.ts +2 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/credit-documents/headless.ts +3 -3
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/headless.ts +3 -2
- package/src/plugins/builtin/doe/pane.tsx +13 -3
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +37 -17
- package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
- package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
- package/src/plugins/builtin/econ/calendar-model.ts +48 -1
- package/src/plugins/builtin/econ/calendar-source.ts +4 -2
- package/src/plugins/builtin/econ/detail-view.tsx +18 -4
- package/src/plugins/builtin/econ/figures.ts +48 -0
- package/src/plugins/builtin/econ/index.tsx +14 -5
- package/src/plugins/builtin/econ/print-watch.ts +3 -1
- package/src/plugins/builtin/econ-statistics/client.ts +11 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
- package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/help/index.tsx +61 -59
- package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
- package/src/plugins/builtin/help/table-cells.tsx +13 -2
- package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
- package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
- package/src/plugins/builtin/holders/headless.ts +4 -2
- package/src/plugins/builtin/holders/pane.tsx +3 -0
- package/src/plugins/builtin/holders/report-rows.ts +4 -1
- package/src/plugins/builtin/insider/headless.ts +2 -2
- package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
- package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
- package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/macro-day/headless.ts +12 -5
- package/src/plugins/builtin/macro-day/index.tsx +1 -1
- package/src/plugins/builtin/macro-day/model.ts +39 -5
- package/src/plugins/builtin/macro-day/pane.tsx +27 -19
- package/src/plugins/builtin/market-movers/client.ts +3 -0
- package/src/plugins/builtin/market-movers/headless.ts +3 -1
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +27 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/mna/headless.ts +2 -0
- package/src/plugins/builtin/mna/pane.tsx +3 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/news/headless.ts +3 -1
- package/src/plugins/builtin/news/index.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/realized-vol/client.ts +13 -5
- package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
- package/src/plugins/builtin/realized-vol/headless.ts +8 -4
- package/src/plugins/builtin/realized-vol/model.ts +37 -6
- package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
- package/src/plugins/builtin/research/event-model.ts +30 -4
- package/src/plugins/builtin/research/index.tsx +7 -3
- package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
- package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
- package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
- package/src/plugins/builtin/risk-factors/data.ts +12 -6
- package/src/plugins/builtin/risk-factors/headless.ts +5 -2
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/roadmap/index.tsx +28 -0
- package/src/plugins/builtin/roadmap/model.ts +67 -0
- package/src/plugins/builtin/roadmap/pane.tsx +244 -0
- package/src/plugins/builtin/scanner/live-headless.ts +21 -2
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/sec/headless.ts +2 -1
- package/src/plugins/builtin/sec/index.tsx +3 -0
- package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/report-freshness.ts +8 -0
- package/src/plugins/builtin/shared/research-cells.tsx +1 -1
- package/src/plugins/builtin/shared/series-cache.ts +4 -4
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +15 -9
- package/src/plugins/builtin/thirteenf/data.ts +39 -11
- package/src/plugins/builtin/thirteenf/headless.ts +31 -4
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
- package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +11 -4
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +59 -1
- package/src/plugins/builtin/yield-curve/headless.ts +9 -6
- package/src/plugins/builtin/yield-curve/index.tsx +34 -10
- package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
- package/src/plugins/catalog-browser.ts +2 -1
- package/src/plugins/host-link.ts +1 -1
- package/src/plugins/installer.ts +50 -39
- package/src/portfolio/column-values.ts +11 -7
- package/src/renderers/dom/desktop/icons.tsx +10 -2
- package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
- package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
- package/src/renderers/electrobun/bun/index.ts +26 -2
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +45 -4
- package/src/renderers/electrobun/bun/window/events.ts +20 -0
- package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
- package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
- package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/shared/protocol.ts +2 -0
- package/src/renderers/electrobun/view/news-source.ts +2 -0
- package/src/renderers/electrobun/view/ui-host.tsx +16 -1
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
- package/src/sources/gloomberb-cloud/index.ts +42 -8
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +110 -40
- package/src/tickers/company-name.ts +71 -2
- package/src/tickers/index-roots.ts +7 -0
- package/src/tickers/listing-fate.ts +2 -2
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +7 -3
- package/src/tickers/search/ranking.ts +5 -1
- package/src/tickers/search/upsert.ts +13 -0
- package/src/tickers/ticker-changes.ts +8 -4
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/company-from.ts +40 -0
- package/src/types/data-provider.ts +7 -0
- package/src/types/financials.ts +45 -3
- package/src/types/headless.ts +6 -0
- package/src/ui/host.tsx +6 -1
- package/src/utils/crypto-pair.ts +121 -7
- package/src/utils/declared-dividends.ts +1 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +53 -10
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- package/src/utils/price-history-integrity.ts +47 -1
- package/src/version.ts +2 -2
|
@@ -31,10 +31,21 @@ export function badgeColumnWidth(
|
|
|
31
31
|
return Math.max(min, Math.min(max, widest));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The chips of a group that fit a column whole, in order. A chip that would be
|
|
36
|
+
* cut ("Ctrl+Hor") is left out instead, so what shows is always a real key; the
|
|
37
|
+
* first chip stays whatever the width.
|
|
38
|
+
*/
|
|
39
|
+
export function wholeBadges(labels: readonly string[], width: number): readonly string[] {
|
|
40
|
+
let count = Math.min(1, labels.length);
|
|
41
|
+
while (count < labels.length && badgeGroupWidth(labels.slice(0, count + 1)) <= width) count += 1;
|
|
42
|
+
return labels.slice(0, count);
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
export function badgeCell(
|
|
35
46
|
labels: readonly string[],
|
|
36
47
|
width: number,
|
|
37
|
-
options: { tone?: "neutral" | "accent"; emptyLabel?: string; emptyColor?: string } = {},
|
|
48
|
+
options: { tone?: "neutral" | "accent"; emptyLabel?: string; emptyColor?: string; wholeOnly?: boolean } = {},
|
|
38
49
|
): DataTableCell {
|
|
39
50
|
if (labels.length === 0) {
|
|
40
51
|
return options.emptyLabel
|
|
@@ -45,7 +56,7 @@ export function badgeCell(
|
|
|
45
56
|
text: labels.join(" "),
|
|
46
57
|
content: (
|
|
47
58
|
<Box flexDirection="row" gap={BADGE_GAP} width={width} height={1} overflow="hidden">
|
|
48
|
-
{labels.map((label, index) => (
|
|
59
|
+
{(options.wholeOnly ? wholeBadges(labels, width) : labels).map((label, index) => (
|
|
49
60
|
<Badge key={`${label}:${index}`} label={label} variant="solid" tone={options.tone ?? "neutral"} />
|
|
50
61
|
))}
|
|
51
62
|
</Box>
|
|
@@ -11,6 +11,7 @@ import { listingAbroad, type IssuerListingParams } from "../../../api-client/pat
|
|
|
11
11
|
import { createPluginCache } from "../../../data/plugin-cache";
|
|
12
12
|
import { secListingKey } from "../../../utils/sec";
|
|
13
13
|
import { finiteOrNull, recordOrNull } from "../../../utils/guards";
|
|
14
|
+
import { readCompanyFrom } from "../../../types/company-from";
|
|
14
15
|
import { cachedCloudResource, loadCloudResource, type CloudResource } from "../shared/cloud-resource";
|
|
15
16
|
import { CLOUD_SESSION_REQUIRED } from "../shared/research-cloud-session";
|
|
16
17
|
|
|
@@ -110,8 +111,11 @@ function normalizeBeneficialOwnersPayload(value: unknown, ticker: string): Benef
|
|
|
110
111
|
if (!raw || !Array.isArray(raw.owners)) throw new Error("The server returned an invalid 13D/13G list");
|
|
111
112
|
const coverage = recordOrNull(raw.coverage);
|
|
112
113
|
const nextOffset = finiteOrNull(raw.nextOffset);
|
|
114
|
+
const companyFrom = readCompanyFrom(raw.companyFrom);
|
|
115
|
+
const { companyFrom: _unread, ...rest } = raw;
|
|
113
116
|
return {
|
|
114
|
-
...
|
|
117
|
+
...rest,
|
|
118
|
+
...(companyFrom ? { companyFrom } : {}),
|
|
115
119
|
ticker: text(raw.ticker) ?? ticker,
|
|
116
120
|
cik: cikOrNull(raw.cik) ?? "",
|
|
117
121
|
companyName: text(raw.companyName) ?? "",
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
type DataTableCell,
|
|
10
10
|
type DataTableKeyEvent,
|
|
11
11
|
} from "../../../components";
|
|
12
|
+
import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
|
|
12
13
|
import { handleRefreshKey, loadingErrorFooterInfo } from "../../../components/data-table/table-pane";
|
|
14
|
+
import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
|
|
13
15
|
import type { IssuerListingParams } from "../../../api-client/paths";
|
|
14
16
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
15
17
|
import { useAutoRefresh } from "../../../react/auto-refresh";
|
|
@@ -42,8 +44,6 @@ import {
|
|
|
42
44
|
import type { Holder13FMatch } from "./thirteenf-match";
|
|
43
45
|
import type { HolderRow } from "./types";
|
|
44
46
|
|
|
45
|
-
const STALE_SEGMENT = { id: "stale", parts: [{ text: "stale", tone: "warning" as const }] };
|
|
46
|
-
|
|
47
47
|
/**
|
|
48
48
|
* A table line: a filer's row, or the line beneath it giving the same shares
|
|
49
49
|
* against today's count when the report's base is no longer today's.
|
|
@@ -233,6 +233,8 @@ export function BeneficialOwnersView({
|
|
|
233
233
|
focused,
|
|
234
234
|
title: "13D/13G coverage",
|
|
235
235
|
});
|
|
236
|
+
// A foreign line's owners are its company's, read from the home line.
|
|
237
|
+
useCompanyDataFooter(payload?.companyFrom);
|
|
236
238
|
usePaneFooter("holders-13dg", () => ({
|
|
237
239
|
info: [
|
|
238
240
|
...(payload?.asOf ? [{ id: "as-of", parts: [{ text: `as of ${payload.asOf.slice(0, 10)}`, tone: "muted" as const }] }] : []),
|
|
@@ -7,7 +7,7 @@ import { loadHolderSnapshot } from "./client";
|
|
|
7
7
|
import { holderValueCurrency } from "./format";
|
|
8
8
|
import { logFailure } from "../../../utils/log-failure";
|
|
9
9
|
import type { HolderData } from "../../../types/financials";
|
|
10
|
-
import { SEC_FILINGS } from "../shared/report-freshness";
|
|
10
|
+
import { SEC_FILING_ROWS, SEC_FILINGS } from "../shared/report-freshness";
|
|
11
11
|
import type { BeneficialOwnersPayload } from "../../../api-client/beneficial-owners";
|
|
12
12
|
import { fetchBeneficialOwners, type BeneficialOwnersRequest } from "./beneficial-client";
|
|
13
13
|
import { resolveHeadlessInstrument, resolveHeadlessIssuerListing } from "../shared/headless-market-data";
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "./report-rows";
|
|
35
35
|
import { REPORT_ROWS } from "../shared/headless-options";
|
|
36
36
|
import { fundHoldersAnswer } from "./fund-holders";
|
|
37
|
+
import { companyDataNotes } from "../../../types/company-from";
|
|
37
38
|
import {
|
|
38
39
|
HOLDER_ROWS_MAX,
|
|
39
40
|
indexedHoldersPartialNote,
|
|
@@ -197,7 +198,7 @@ export function createHoldersHeadless(
|
|
|
197
198
|
rows: limited,
|
|
198
199
|
// Reports that could not be read this time are missing from the rows.
|
|
199
200
|
complete: beneficialListComplete(payload),
|
|
200
|
-
freshness: { ...
|
|
201
|
+
freshness: { ...SEC_FILING_ROWS, basis: "13D/13G filings" },
|
|
201
202
|
metadata: {
|
|
202
203
|
symbol: payload.ticker || symbol,
|
|
203
204
|
name: payload.companyName || null,
|
|
@@ -212,6 +213,7 @@ export function createHoldersHeadless(
|
|
|
212
213
|
[payload.companyName || symbol, ...beneficialListFacts(payload, form, rows.length, history)].join(" · "),
|
|
213
214
|
...(legend ? [legend] : []),
|
|
214
215
|
...beneficialCoverageNotices(payload.coverage),
|
|
216
|
+
...companyDataNotes(payload.companyFrom),
|
|
215
217
|
],
|
|
216
218
|
returned: limited.length,
|
|
217
219
|
total: rows.length,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type DataTableKeyEvent,
|
|
13
13
|
type PageRequest,
|
|
14
14
|
} from "../../../components";
|
|
15
|
+
import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
|
|
15
16
|
import { handleRefreshKey, loadingErrorFooterInfo } from "../../../components/data-table/table-pane";
|
|
16
17
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
17
18
|
import { useShortcut } from "../../../react/input";
|
|
@@ -321,6 +322,8 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
|
|
|
321
322
|
}
|
|
322
323
|
}, [currency, fundMatches, marketCap]);
|
|
323
324
|
|
|
325
|
+
// Holders read from the company's home line say whose; the SEC index's rows are the listing's own.
|
|
326
|
+
useCompanyDataFooter(indexed ? null : listData?.companyFrom, viewMode !== "13dg");
|
|
324
327
|
// The 13D/G tab registers its own footer.
|
|
325
328
|
usePaneFooter("holders", () => {
|
|
326
329
|
if (viewMode === "13dg") return null;
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { HOLDER_ROWS_MAX } from "./index-holders";
|
|
26
26
|
import { buildRows, DEFAULT_SORT, sortRows } from "./table-model";
|
|
27
27
|
import type { HolderColumnId, SortPreference } from "./types";
|
|
28
|
+
import { companyDataNotes } from "../../../types/company-from";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* A holder as `holders` and `fn HDS` report it; the keys are the JSON
|
|
@@ -231,7 +232,9 @@ export function holderReportNotices({
|
|
|
231
232
|
? `The source lists its ${reported} largest holders; fn 13F ${requested} lists all ${total.toLocaleString("en-US")}.`
|
|
232
233
|
: null;
|
|
233
234
|
return {
|
|
234
|
-
notices: [header, basis, cut, positionsBasis, holderShareBasisNote(data, shown), unvaluedHoldersNote(shown, sort)
|
|
235
|
+
notices: [header, basis, cut, positionsBasis, holderShareBasisNote(data, shown), unvaluedHoldersNote(shown, sort),
|
|
236
|
+
// The SEC index's rows are the listing's own; the source's, its company's home line's when it says so.
|
|
237
|
+
...(indexed ? [] : companyDataNotes(data.companyFrom))]
|
|
235
238
|
.filter((line): line is string => !!line),
|
|
236
239
|
valueBasis,
|
|
237
240
|
positionsBasis,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./model";
|
|
21
21
|
import { isAmendedInsiderFiling, relevantInsiderAmendments } from "./amendments";
|
|
22
22
|
import { formatInsiderName } from "./display";
|
|
23
|
-
import {
|
|
23
|
+
import { SEC_FILING_ROWS } from "../shared/report-freshness";
|
|
24
24
|
import { resolveHeadlessInstrument, resolveHeadlessIssuerListing } from "../shared/headless-market-data";
|
|
25
25
|
import { noSecReportsNotice } from "../../../utils/sec";
|
|
26
26
|
import { parsePublicTickerKey } from "../../../utils/exchanges";
|
|
@@ -329,7 +329,7 @@ export function createInsiderHeadless(
|
|
|
329
329
|
): HeadlessPaneDefinition<"rows"> {
|
|
330
330
|
return {
|
|
331
331
|
shape: "rows",
|
|
332
|
-
freshness: { ...
|
|
332
|
+
freshness: { ...SEC_FILING_ROWS, basis: "Form 4 filings" },
|
|
333
333
|
argument: {
|
|
334
334
|
kind: "ticker",
|
|
335
335
|
optional: true,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
|
-
import type { IpoCalendarParams, IpoCalendarPayload, IpoDeal, IpoSourceHealth } from "../../../api-client/ipo";
|
|
2
|
+
import type { IpoCalendarParams, IpoCalendarPayload, IpoDeal, IpoLockup, IpoSourceHealth } from "../../../api-client/ipo";
|
|
3
3
|
import type { HeadlessPaneApiClient } from "../../../types/plugin";
|
|
4
4
|
import { createPluginCache } from "../../../data/plugin-cache";
|
|
5
5
|
import { cachedCloudResource, loadCloudResource, unavailableOnServer, type CloudResource } from "../shared/cloud-resource";
|
|
@@ -45,6 +45,21 @@ function validDeal(deal: IpoDeal): boolean {
|
|
|
45
45
|
&& validFirstDay(deal.firstDay);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function validLockup(value: unknown): value is IpoLockup {
|
|
49
|
+
const lockup = value as IpoLockup | null;
|
|
50
|
+
return !!lockup && typeof lockup === "object" && typeof lockup.endsOn === "string" && dayOrNull(lockup.endsOn)
|
|
51
|
+
&& typeof lockup.days === "number" && Number.isFinite(lockup.days) && lockup.days > 0
|
|
52
|
+
&& (lockup.basis === "prospectus" || lockup.basis === "typical")
|
|
53
|
+
&& dayOrNull(lockup.prospectusDate ?? null) && textOrNull(lockup.prospectusUrl ?? null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A lock-up estimate the client cannot read is left off its deal; the deal stays. */
|
|
57
|
+
function withReadableLockup(deal: IpoDeal): IpoDeal {
|
|
58
|
+
if (deal.lockup == null || validLockup(deal.lockup)) return deal;
|
|
59
|
+
const { lockup: _unreadable, ...rest } = deal;
|
|
60
|
+
return rest;
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
function validSource(source: IpoSourceHealth): boolean {
|
|
49
64
|
return !!source && text(source.id) && Array.isArray(source.mics) && source.mics.every(text)
|
|
50
65
|
&& typeof source.ok === "boolean" && textOrNull(source.asOf);
|
|
@@ -61,7 +76,8 @@ export function validateIpoCalendar(data: IpoCalendarPayload): IpoCalendarPayloa
|
|
|
61
76
|
}
|
|
62
77
|
return {
|
|
63
78
|
asOf: data.asOf,
|
|
64
|
-
deals: data.deals.filter(validDeal).map(
|
|
79
|
+
deals: data.deals.filter(validDeal).map(withReadableLockup)
|
|
80
|
+
.map((deal) => (REGIONS.has(deal.region) ? deal : { ...deal, region: "other" })),
|
|
65
81
|
sources: data.sources.filter(validSource),
|
|
66
82
|
};
|
|
67
83
|
}
|
|
@@ -9,11 +9,13 @@ import { fetchIpoCalendar } from "./client";
|
|
|
9
9
|
import {
|
|
10
10
|
calendarDate,
|
|
11
11
|
filterIpoDeals,
|
|
12
|
+
formatIpoLockupEnd,
|
|
12
13
|
formatIpoPrice,
|
|
13
14
|
formatIpoReturn,
|
|
14
15
|
formatIpoSize,
|
|
15
16
|
IPO_STATUSES,
|
|
16
17
|
ipoBoardStatus,
|
|
18
|
+
ipoKeyword,
|
|
17
19
|
isIpoTab,
|
|
18
20
|
marketsBehind,
|
|
19
21
|
marketsBehindText,
|
|
@@ -21,7 +23,10 @@ import {
|
|
|
21
23
|
DEFAULT_IPO_SORT,
|
|
22
24
|
type IpoTab,
|
|
23
25
|
} from "./model";
|
|
26
|
+
import { ipoDetailSentences, loadIpoLockupNews, type IpoLockupStory } from "./listing-ipo";
|
|
24
27
|
import { REPORT_FILTER, REPORT_ROWS } from "../shared/headless-options";
|
|
28
|
+
import { DATE_OPTION_EXAMPLES, isoDateOption } from "../../../utils/calendar-date";
|
|
29
|
+
import { logFailure } from "../../../utils/log-failure";
|
|
25
30
|
|
|
26
31
|
const COLUMNS: HeadlessPaneColumn[] = [
|
|
27
32
|
{ key: "ticker", header: "Ticker" },
|
|
@@ -33,6 +38,8 @@ const COLUMNS: HeadlessPaneColumn[] = [
|
|
|
33
38
|
{ key: "price", header: "Price", align: "right" },
|
|
34
39
|
{ key: "offerSizeUsd", header: "Size", align: "right", format: (value) => formatIpoSize(value as number | null) },
|
|
35
40
|
{ key: "firstDayReturn", header: "Return", align: "right", format: (value) => formatIpoReturn(value as number | null) },
|
|
41
|
+
// A US IPO's estimated lock-up end: "~2026-11-02".
|
|
42
|
+
{ key: "lockupEnds", header: "Lockup ends", format: (value) => formatIpoLockupEnd(typeof value === "string" ? value : null) },
|
|
36
43
|
];
|
|
37
44
|
|
|
38
45
|
type StatusOption = IpoStatus | "all";
|
|
@@ -68,7 +75,7 @@ function toRow(deal: IpoDeal, now: Date) {
|
|
|
68
75
|
status: ipoBoardStatus(deal, now),
|
|
69
76
|
listingType: deal.listingType,
|
|
70
77
|
currency: deal.currency,
|
|
71
|
-
price: formatIpoPrice(deal),
|
|
78
|
+
price: formatIpoPrice(deal, { full: true }),
|
|
72
79
|
priceLow: deal.priceLow,
|
|
73
80
|
priceHigh: deal.priceHigh,
|
|
74
81
|
offerPrice: deal.offerPrice,
|
|
@@ -78,26 +85,60 @@ function toRow(deal: IpoDeal, now: Date) {
|
|
|
78
85
|
firstDayClose: deal.firstDay?.close ?? null,
|
|
79
86
|
firstDayReturn: deal.firstDay?.returnPct ?? null,
|
|
80
87
|
filedDate: deal.filedDate,
|
|
88
|
+
lockupEnds: deal.lockup?.endsOn ?? null,
|
|
89
|
+
lockupBasis: deal.lockup?.basis ?? null,
|
|
90
|
+
prospectusDate: deal.lockup?.prospectusDate ?? null,
|
|
81
91
|
};
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
|
|
94
|
+
/** One deal found: its IPO in words, as `ticker` gives it, with its price now and its lock-up news. */
|
|
95
|
+
interface IpoDealDetail {
|
|
96
|
+
price: number | null;
|
|
97
|
+
news: IpoLockupStory[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Lock-up estimates soonest first, then the ones that have ended, latest first. */
|
|
101
|
+
function byLockupEnd(deals: readonly IpoDeal[], now: Date): IpoDeal[] {
|
|
102
|
+
const today = now.toISOString().slice(0, 10);
|
|
103
|
+
const rank = (deal: IpoDeal) => (deal.lockup!.endsOn >= today ? 0 : 1);
|
|
104
|
+
return [...deals].sort((a, b) => rank(a) - rank(b)
|
|
105
|
+
|| (rank(a) === 0 ? a.lockup!.endsOn.localeCompare(b.lockup!.endsOn) : b.lockup!.endsOn.localeCompare(a.lockup!.endsOn))
|
|
106
|
+
|| a.id.localeCompare(b.id));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The most rows `--limit` lists. */
|
|
110
|
+
const MAX_ROWS = 500;
|
|
111
|
+
|
|
112
|
+
const SPAC_NOTE = "A SPAC's merger with its target (de-SPAC) is no IPO, so the calendar lists the SPACs' own IPOs.";
|
|
113
|
+
|
|
114
|
+
export function projectIpoCalendarHeadless(
|
|
115
|
+
payload: IpoCalendarPayload,
|
|
116
|
+
args: HeadlessPaneLoadArgs,
|
|
117
|
+
detail: IpoDealDetail | null = null,
|
|
118
|
+
now = new Date(),
|
|
119
|
+
): HeadlessRowsResult {
|
|
85
120
|
const query = typeof args.argument === "string" ? args.argument.trim() : "";
|
|
86
121
|
const region = regionOption(args);
|
|
87
122
|
const status = statusOption(args);
|
|
88
123
|
const limit = Number(args.options.limit ?? 50);
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
DEFAULT_IPO_SORT,
|
|
93
|
-
now,
|
|
94
|
-
);
|
|
124
|
+
const keyword = ipoKeyword(query);
|
|
125
|
+
const found = filterIpoDeals(payload.deals, region, query, now).filter((deal) => status === "all" || deal.status === status);
|
|
126
|
+
const matching = keyword === "lockup" ? byLockupEnd(found, now) : sortIpoDeals(found, DEFAULT_IPO_SORT, now);
|
|
95
127
|
const rows = matching.slice(0, limit).map((deal) => toRow(deal, now));
|
|
96
128
|
const behind = marketsBehindText(marketsBehind(payload.sources, region, payload.deals));
|
|
129
|
+
const single = matching.length === 1 && query && !keyword ? matching[0]! : null;
|
|
130
|
+
const notes = [
|
|
131
|
+
...rows.length < matching.length
|
|
132
|
+
? [`Showing ${rows.length} of ${matching.length} deals; --limit ${Math.min(matching.length, MAX_ROWS)} lists ${matching.length <= MAX_ROWS ? "them all" : "more"}.`]
|
|
133
|
+
: [],
|
|
134
|
+
...single ? ipoDetailSentences(single, detail?.price ?? null, detail?.news ?? [], now) : [],
|
|
135
|
+
...keyword === "spac" || keyword === "de-spac" ? [SPAC_NOTE] : [],
|
|
136
|
+
];
|
|
97
137
|
return {
|
|
98
138
|
columns: COLUMNS,
|
|
99
139
|
rows,
|
|
100
140
|
...(behind ? { errors: [behind] } : {}),
|
|
141
|
+
...(notes.length ? { notes } : {}),
|
|
101
142
|
metadata: {
|
|
102
143
|
asOf: payload.asOf,
|
|
103
144
|
total: matching.length,
|
|
@@ -149,7 +190,15 @@ export const ipoCalendarHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
|
149
190
|
type: "integer",
|
|
150
191
|
defaultValue: 50,
|
|
151
192
|
minimum: 1,
|
|
152
|
-
maximum:
|
|
193
|
+
maximum: MAX_ROWS,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
key: "from",
|
|
197
|
+
type: "string",
|
|
198
|
+
placeholder: "date",
|
|
199
|
+
description: `First listing date, as ${DATE_OPTION_EXAMPLES}; the calendar keeps a year of listings.`,
|
|
200
|
+
example: "--from 2025-11-01",
|
|
201
|
+
normalize: (value) => isoDateOption(value, "from"),
|
|
153
202
|
},
|
|
154
203
|
],
|
|
155
204
|
columns: COLUMNS,
|
|
@@ -164,7 +213,22 @@ export const ipoCalendarHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
|
164
213
|
async load(args, ctx) {
|
|
165
214
|
// The default window has no filed or withdrawn deals, so a stage is asked for by name.
|
|
166
215
|
const status = statusOption(args);
|
|
167
|
-
const
|
|
168
|
-
|
|
216
|
+
const from = typeof args.options.from === "string" && args.options.from ? args.options.from : undefined;
|
|
217
|
+
const params = { ...(status === "all" ? {} : { status }), ...(from ? { from } : {}) };
|
|
218
|
+
const payload = await fetchIpoCalendar(ctx.apiClient, ctx.signal, params);
|
|
219
|
+
const projected = projectIpoCalendarHeadless(payload, args);
|
|
220
|
+
// One deal found: its price now and its lock-up news, which never fail the report.
|
|
221
|
+
const query = typeof args.argument === "string" ? args.argument.trim() : "";
|
|
222
|
+
const matched = projected.rows.length === 1 && query && !ipoKeyword(query)
|
|
223
|
+
? payload.deals.find((deal) => deal.id === projected.rows[0]!.id) : undefined;
|
|
224
|
+
if (!matched?.symbol) return projected;
|
|
225
|
+
const [price, news] = await Promise.all([
|
|
226
|
+
matched.status === "listed"
|
|
227
|
+
? ctx.marketData.getQuote(matched.symbol, matched.exchange ?? undefined).then((quote) => quote.price ?? null,
|
|
228
|
+
logFailure("plugins.ipo-calendar", "Load the price of an IPO failed", null))
|
|
229
|
+
: Promise.resolve(null),
|
|
230
|
+
loadIpoLockupNews(ctx.apiClient, matched).catch(logFailure("plugins.ipo-calendar", "Load an IPO's lock-up news failed", [] as IpoLockupStory[])),
|
|
231
|
+
]);
|
|
232
|
+
return projectIpoCalendarHeadless(payload, args, { price, news });
|
|
169
233
|
},
|
|
170
234
|
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { apiClient } from "../../../api-client";
|
|
2
|
+
import type { IpoDeal } from "../../../api-client/ipo";
|
|
3
|
+
import type { HeadlessPaneApiClient } from "../../../types/plugin";
|
|
4
|
+
import { canonicalExchange } from "../../../utils/exchanges";
|
|
5
|
+
import { fetchIpoCalendar } from "./client";
|
|
6
|
+
import {
|
|
7
|
+
formatIpoAmount,
|
|
8
|
+
formatIpoPrice,
|
|
9
|
+
formatIpoReturn,
|
|
10
|
+
ipoLockupBasis,
|
|
11
|
+
ipoLockupText,
|
|
12
|
+
ipoReturnSince,
|
|
13
|
+
ipoTickerKey,
|
|
14
|
+
isLockupStory,
|
|
15
|
+
} from "./model";
|
|
16
|
+
|
|
17
|
+
/** How far back a listing's IPO is looked for: the calendar keeps a year, and a lock-up runs about half of one. */
|
|
18
|
+
const IPO_LOOKBACK_DAYS = 400;
|
|
19
|
+
const LOCKUP_NEWS_LIMIT = 3;
|
|
20
|
+
|
|
21
|
+
/** A lock-up story filed under a listing: its headline, publisher and day. */
|
|
22
|
+
export interface IpoLockupStory {
|
|
23
|
+
title: string;
|
|
24
|
+
source: string;
|
|
25
|
+
publishedAt: string;
|
|
26
|
+
url: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The stories filed under a listing about its lock-up since it listed (an early release, a waiver), newest first. */
|
|
30
|
+
export async function loadIpoLockupNews(
|
|
31
|
+
client: Pick<HeadlessPaneApiClient, "searchCloudNews">,
|
|
32
|
+
deal: IpoDeal,
|
|
33
|
+
): Promise<IpoLockupStory[]> {
|
|
34
|
+
const key = ipoTickerKey(deal);
|
|
35
|
+
if (!key || !deal.lockup) return [];
|
|
36
|
+
const response = await client.searchCloudNews("lock-up", 20, undefined, { tickers: [key] });
|
|
37
|
+
const since = deal.listingDate ?? deal.firstDay?.session ?? "";
|
|
38
|
+
return response.items
|
|
39
|
+
.map((item) => ({
|
|
40
|
+
title: item.headline,
|
|
41
|
+
source: item.primaryPublisher || item.primarySource,
|
|
42
|
+
publishedAt: item.lastPublishedAt || item.firstPublishedAt,
|
|
43
|
+
url: item.primaryUrl,
|
|
44
|
+
}))
|
|
45
|
+
.filter((story) => isLockupStory(story.title) && story.publishedAt.slice(0, 10) >= since)
|
|
46
|
+
.slice(0, LOCKUP_NEWS_LIMIT);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function shiftDays(day: string, days: number): string {
|
|
50
|
+
return new Date(Date.parse(`${day}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The IPO a listing came to market with in the last year or so, from the
|
|
55
|
+
* IPO calendar: the deal listed under its symbol on its exchange. Null for a
|
|
56
|
+
* listing older than that, one the calendar does not have, or one whose
|
|
57
|
+
* symbol two deals on its exchange share.
|
|
58
|
+
*/
|
|
59
|
+
export async function loadListingIpo(
|
|
60
|
+
client: Pick<HeadlessPaneApiClient, "getCloudIpoCalendar">,
|
|
61
|
+
listing: { symbol: string; exchange?: string | null },
|
|
62
|
+
{ signal, now = new Date() }: { signal?: AbortSignal; now?: Date } = {},
|
|
63
|
+
): Promise<IpoDeal | null> {
|
|
64
|
+
const symbol = listing.symbol.trim().toUpperCase();
|
|
65
|
+
if (!symbol) return null;
|
|
66
|
+
const today = now.toISOString().slice(0, 10);
|
|
67
|
+
const payload = await fetchIpoCalendar(client, signal, { q: symbol, from: shiftDays(today, -IPO_LOOKBACK_DAYS), to: today });
|
|
68
|
+
const exchange = listing.exchange ? canonicalExchange(listing.exchange) : "";
|
|
69
|
+
const deals = payload.deals.filter((deal) => (deal.status === "listed" || deal.status === "priced")
|
|
70
|
+
&& deal.symbol?.toUpperCase() === symbol
|
|
71
|
+
&& (!exchange || (deal.exchange != null && canonicalExchange(deal.exchange) === exchange)));
|
|
72
|
+
return deals.length === 1 ? deals[0]! : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A deal's IPO in rows of a report, label and text:
|
|
77
|
+
* - IPO date 2026-05-07 on NYSE
|
|
78
|
+
* - Offer $26 (or "direct listing, no offer price")
|
|
79
|
+
* - Since IPO +62.7% ($26 to $42.30)
|
|
80
|
+
* - Lockup lockup ends ~2026-11-02 (estimate)
|
|
81
|
+
*/
|
|
82
|
+
export function ipoMetricRows(deal: IpoDeal, price: number | null | undefined, now = new Date()): Array<[string, string]> {
|
|
83
|
+
const day = deal.listingDate ?? deal.firstDay?.session ?? null;
|
|
84
|
+
const rows: Array<[string, string]> = [];
|
|
85
|
+
if (day) rows.push(["IPO date", `${day}${deal.venue ? ` on ${deal.venue}` : ""}`]);
|
|
86
|
+
rows.push(["Offer", formatIpoPrice(deal, { full: true })]);
|
|
87
|
+
const since = ipoReturnSince(deal, price);
|
|
88
|
+
if (since) {
|
|
89
|
+
const from = since.basis === "offer" ? formatIpoAmount(deal, since.from) : `first trade ${formatIpoAmount(deal, since.from)}`;
|
|
90
|
+
rows.push(["Since IPO", `${formatIpoReturn(since.returnPct)} (${from} to ${formatIpoAmount(deal, price!)})`]);
|
|
91
|
+
}
|
|
92
|
+
const lockup = ipoLockupText(deal, now);
|
|
93
|
+
if (lockup) rows.push(["Lockup", lockup.replace(/^lockup /, "")]);
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The lines under those rows: what the lock-up estimate counts from, then the lock-up stories. */
|
|
98
|
+
export function ipoDetailNotes(deal: IpoDeal, news: readonly IpoLockupStory[]): string[] {
|
|
99
|
+
const basis = ipoLockupBasis(deal);
|
|
100
|
+
return [
|
|
101
|
+
...basis ? [`Lockup estimate: ${basis}; underwriters can release shares early.`] : [],
|
|
102
|
+
...news.map((story) => `Lock-up news ${story.publishedAt.slice(0, 10)}: ${story.title} (${story.source})`),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A deal's IPO in sentences, for a report's notes:
|
|
108
|
+
* - "HAWK listed 2026-05-07 on NYSE at a $26 offer price; $42.30 now, +62.7% since the IPO."
|
|
109
|
+
* - "GYGY listed 2026-07-30 on Nasdaq: direct listing, no offer price; $30.10 now, +33.8% since the first trade at $22.50."
|
|
110
|
+
* - "HAWK lockup ends ~2026-11-02 (estimate): 180 days from the prospectus dated 2026-05-06; underwriters can release shares early."
|
|
111
|
+
* - "Lock-up news 2026-08-31: HawkEye 360 Confirms Early Lock-up Release for September 2nd (PR Newswire)"
|
|
112
|
+
*/
|
|
113
|
+
export function ipoDetailSentences(deal: IpoDeal, price: number | null | undefined, news: readonly IpoLockupStory[], now = new Date()): string[] {
|
|
114
|
+
const name = deal.symbol ?? deal.company;
|
|
115
|
+
const day = deal.listingDate ?? deal.firstDay?.session ?? null;
|
|
116
|
+
const listed = deal.status === "listed" ? "listed" : "prices to list";
|
|
117
|
+
const where = `${day ? ` ${day}` : ""}${deal.venue ? ` on ${deal.venue}` : ""}`;
|
|
118
|
+
const offer = deal.offerPrice != null ? ` at a ${formatIpoAmount(deal, deal.offerPrice)} offer price`
|
|
119
|
+
: deal.listingType === "direct" ? `: ${formatIpoPrice(deal, { full: true })}` : "";
|
|
120
|
+
// The table's Return is the first day's; the sentence says which is which.
|
|
121
|
+
const firstDay = deal.firstDay?.returnPct != null && deal.offerPrice != null
|
|
122
|
+
? `, ${formatIpoReturn(deal.firstDay.returnPct)} on the first day` : "";
|
|
123
|
+
const since = ipoReturnSince(deal, price);
|
|
124
|
+
const priceNow = since ? `; ${formatIpoAmount(deal, price!)} now, ${formatIpoReturn(since.returnPct)} since ${
|
|
125
|
+
since.basis === "offer" ? "the IPO" : `the first trade at ${formatIpoAmount(deal, since.from)}`}` : "";
|
|
126
|
+
const lockup = ipoLockupText(deal, now);
|
|
127
|
+
const basis = ipoLockupBasis(deal);
|
|
128
|
+
return [
|
|
129
|
+
`${name} ${listed}${where}${offer}${firstDay}${priceNow}.`,
|
|
130
|
+
...lockup ? [`${name} ${lockup}${basis ? `: ${basis}` : ""}; underwriters can release shares early.`] : [],
|
|
131
|
+
...news.map((story) => `Lock-up news ${story.publishedAt.slice(0, 10)}: ${story.title} (${story.source})`),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A listing's recent IPO and its lock-up news, as `ticker` reads them beside the rest. */
|
|
136
|
+
export interface TickerIpo {
|
|
137
|
+
deal: IpoDeal;
|
|
138
|
+
news: IpoLockupStory[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** How long `ticker` waits for the IPO calendar before leaving the section out. */
|
|
142
|
+
const TICKER_IPO_WAIT_MS = 6_000;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A stock's IPO of the last year or so with its lock-up news, for `ticker`;
|
|
146
|
+
* null for any other listing, and when the calendar does not answer in time.
|
|
147
|
+
*/
|
|
148
|
+
export async function loadTickerIpo(
|
|
149
|
+
listing: { symbol: string; exchange?: string | null },
|
|
150
|
+
client: Pick<HeadlessPaneApiClient, "getCloudIpoCalendar" | "searchCloudNews"> = apiClient,
|
|
151
|
+
): Promise<TickerIpo | null> {
|
|
152
|
+
const signal = AbortSignal.timeout(TICKER_IPO_WAIT_MS);
|
|
153
|
+
const deal = await loadListingIpo(client, listing, { signal });
|
|
154
|
+
if (!deal) return null;
|
|
155
|
+
const news = await loadIpoLockupNews(client, deal).catch(() => {
|
|
156
|
+
// Intentional ignore: the IPO's rows stand without its lock-up news.
|
|
157
|
+
return [];
|
|
158
|
+
});
|
|
159
|
+
return { deal, news };
|
|
160
|
+
}
|
|
@@ -161,16 +161,36 @@ const REGION_WORDS: Partial<Record<IpoRegion, readonly string[]>> = {
|
|
|
161
161
|
apac: ["apac", "asia", "asia-pacific"],
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Words that name a kind of deal rather than one: `lockup` (`lock-up`,
|
|
166
|
+
* `lock up`, `lockups`) lists the deals with a lock-up estimate, `spac` the
|
|
167
|
+
* SPACs' own IPOs, and `de-spac` (`despac`) those too: a SPAC's merger with
|
|
168
|
+
* its target is no IPO, so the calendar has none of its own.
|
|
169
|
+
*/
|
|
170
|
+
export type IpoKeyword = "lockup" | "spac" | "de-spac";
|
|
171
|
+
|
|
172
|
+
export function ipoKeyword(query: string): IpoKeyword | null {
|
|
173
|
+
const word = query.trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
174
|
+
if (/^lockups?$/.test(word)) return "lockup";
|
|
175
|
+
if (/^spacs?$/.test(word)) return "spac";
|
|
176
|
+
if (/^despacs?$/.test(word)) return "de-spac";
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
164
180
|
/**
|
|
165
181
|
* Company in either script, symbol, venue, country code or name, and the
|
|
166
182
|
* status the board shows. The word `pending` is no status of its own: it is
|
|
167
183
|
* every deal not priced yet, offering and overdue ones included.
|
|
168
184
|
* A region's name (`europe`, `asia`) lists the region's deals, on whichever tab is open.
|
|
185
|
+
* A kind of deal (`lockup`, `spac`, `de-spac`, see ipoKeyword) lists that kind.
|
|
169
186
|
*/
|
|
170
187
|
function matchesIpoQuery(deal: IpoDeal, query: string, now: Date): boolean {
|
|
171
188
|
const needle = query.trim().toLowerCase();
|
|
172
189
|
if (!needle) return true;
|
|
173
190
|
if (needle === "pending") return NOT_PRICED_STATUSES.includes(deal.status);
|
|
191
|
+
const keyword = ipoKeyword(needle);
|
|
192
|
+
if (keyword === "lockup") return !!deal.lockup;
|
|
193
|
+
if (keyword) return deal.listingType === "spac";
|
|
174
194
|
if (REGION_WORDS[deal.region]?.includes(needle.replace(/\s+/g, "-"))) return true;
|
|
175
195
|
return [
|
|
176
196
|
deal.company, deal.companyLocal, deal.symbol, deal.venue, deal.mic, deal.exchange,
|
|
@@ -343,9 +363,17 @@ function amount(value: number): string {
|
|
|
343
363
|
return value.toLocaleString("en-US", { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: 2 });
|
|
344
364
|
}
|
|
345
365
|
|
|
346
|
-
/**
|
|
347
|
-
|
|
366
|
+
/** What a direct listing with no price reads in a column; a report's row says why in full. */
|
|
367
|
+
const DIRECT_LISTING_PRICE = "direct listing";
|
|
368
|
+
const DIRECT_LISTING_NO_PRICE = "direct listing, no offer price";
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* "$18", "HK$26.20-28.50", "240-260p", "CHF 45": the offer price once set, else the range.
|
|
372
|
+
* A direct listing sells no shares at a price: "direct listing" (`full`: "direct listing, no offer price").
|
|
373
|
+
*/
|
|
374
|
+
export function formatIpoPrice(deal: IpoDeal, { full = false }: { full?: boolean } = {}): string {
|
|
348
375
|
const low = deal.offerPrice ?? deal.priceLow ?? deal.priceHigh;
|
|
376
|
+
if (low == null && deal.listingType === "direct") return full ? DIRECT_LISTING_NO_PRICE : DIRECT_LISTING_PRICE;
|
|
349
377
|
if (low == null) return MISSING;
|
|
350
378
|
const high = deal.offerPrice == null && deal.priceLow != null && deal.priceHigh != null && deal.priceHigh !== deal.priceLow
|
|
351
379
|
? deal.priceHigh
|
|
@@ -387,3 +415,71 @@ export function formatIpoDate(value: string | null, now = new Date()): string {
|
|
|
387
415
|
? `${month} ${date.getUTCDate()}`
|
|
388
416
|
: `${month} ${date.getUTCFullYear()}`;
|
|
389
417
|
}
|
|
418
|
+
|
|
419
|
+
/** "~2026-11-02": when a deal's lock-up is estimated to end; "—" without an estimate. */
|
|
420
|
+
export function formatIpoLockupEnd(endsOn: string | null | undefined): string {
|
|
421
|
+
return endsOn ? `~${endsOn}` : MISSING;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Today in UTC, YYYY-MM-DD. */
|
|
425
|
+
function utcToday(now: Date): string {
|
|
426
|
+
return now.toISOString().slice(0, 10);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* "lockup ends ~2026-11-02 (estimate)", "lockup ended ~2026-07-28 (estimate)":
|
|
431
|
+
* a deal's lock-up estimate in words; null without one.
|
|
432
|
+
*/
|
|
433
|
+
export function ipoLockupText(deal: Pick<IpoDeal, "lockup">, now = new Date()): string | null {
|
|
434
|
+
if (!deal.lockup) return null;
|
|
435
|
+
const verb = deal.lockup.endsOn < utcToday(now) ? "ended" : "ends";
|
|
436
|
+
return `lockup ${verb} ~${deal.lockup.endsOn} (estimate)`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What the estimate counts from, as the server made it. The period is the
|
|
441
|
+
* prospectus's own (`prospectus`) or the typical 180 days; it counts from
|
|
442
|
+
* the prospectus date when one was read, else from the first session:
|
|
443
|
+
* - "180 days from the prospectus dated 2026-05-06"
|
|
444
|
+
* - "90 days from the first session on 2026-05-07, the period the prospectus states"
|
|
445
|
+
* - "the typical 180 days from the prospectus dated 2026-05-06"
|
|
446
|
+
* - "the typical 180 days from the first session on 2026-05-07; the prospectus has not been read"
|
|
447
|
+
*/
|
|
448
|
+
export function ipoLockupBasis(deal: Pick<IpoDeal, "lockup" | "listingDate">): string | null {
|
|
449
|
+
const lockup = deal.lockup;
|
|
450
|
+
if (!lockup) return null;
|
|
451
|
+
const stated = lockup.basis === "prospectus";
|
|
452
|
+
const from = lockup.prospectusDate
|
|
453
|
+
? `from the prospectus dated ${lockup.prospectusDate}`
|
|
454
|
+
: `from the first session${deal.listingDate ? ` on ${deal.listingDate}` : ""}`;
|
|
455
|
+
if (stated) return `${lockup.days} days ${from}${lockup.prospectusDate ? "" : ", the period the prospectus states"}`;
|
|
456
|
+
// A prospectus read with no period it states (or none of a lock-up's length) is still read.
|
|
457
|
+
const read = lockup.prospectusDate != null || lockup.prospectusUrl != null;
|
|
458
|
+
return `the typical ${lockup.days} days ${from}${read ? "" : "; the prospectus has not been read"}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A price in the deal's own currency: "$26", "HK$7.20". */
|
|
462
|
+
export function formatIpoAmount(deal: Pick<IpoDeal, "currency">, value: number): string {
|
|
463
|
+
return formatIpoPrice({ offerPrice: value, priceLow: null, priceHigh: null, currency: deal.currency, listingType: "ipo" } as IpoDeal);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* What a listed deal's price has done since it listed: from the offer price,
|
|
468
|
+
* else (a direct listing) from its first trade. Null without a price now or a
|
|
469
|
+
* price to measure from.
|
|
470
|
+
*/
|
|
471
|
+
export function ipoReturnSince(deal: IpoDeal, price: number | null | undefined): { from: number; basis: "offer" | "first trade"; returnPct: number } | null {
|
|
472
|
+
if (price == null || !Number.isFinite(price) || price <= 0) return null;
|
|
473
|
+
const offer = deal.offerPrice;
|
|
474
|
+
if (offer != null && offer > 0) return { from: offer, basis: "offer", returnPct: price / offer - 1 };
|
|
475
|
+
const open = deal.firstDay?.open;
|
|
476
|
+
if (deal.listingType === "direct" && open != null && open > 0) return { from: open, basis: "first trade", returnPct: price / open - 1 };
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The lock-up stories worth listing beside a deal: on or after its listing, about its lock-up. */
|
|
481
|
+
const LOCKUP_WORDS = /\block[\s-]?ups?\b/i;
|
|
482
|
+
|
|
483
|
+
export function isLockupStory(title: string): boolean {
|
|
484
|
+
return LOCKUP_WORDS.test(title);
|
|
485
|
+
}
|