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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gloomberb",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
4
4
|
"packageManager": "bun@1.3.11",
|
|
5
5
|
"description": "Market research and portfolio tracker for the terminal",
|
|
6
6
|
"module": "src/public/index.ts",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"check:pane-screenshot": "bun run scripts/pane-chrome/screenshot.ts",
|
|
87
87
|
"check:pane-rest": "bun run scripts/pane-chrome/rest-diff.ts",
|
|
88
88
|
"check:pane-layout": "bun run scripts/pane-chrome/layout-sweep.ts",
|
|
89
|
+
"audit:ui": "bun run scripts/pane-chrome/ui-audit.ts",
|
|
89
90
|
"benchmark:pane-drag:compare": "bun run scripts/pane-chrome/frame-budget.ts",
|
|
90
91
|
"test:global-upgrade": "bun run scripts/smoke-global-upgrade.ts",
|
|
91
92
|
"release": "./scripts/release.sh"
|
package/src/api-client/chat.ts
CHANGED
|
@@ -154,16 +154,40 @@ export class CloudChatApi {
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* `seen`: the app showed them where the reader was looking (the open
|
|
159
|
+
* channel, a toast in the foreground), which also cancels the phone push
|
|
160
|
+
* waiting on them. A server that predates it ignores the field.
|
|
161
|
+
*/
|
|
157
162
|
async markNotificationsDelivered(
|
|
158
163
|
notificationIds: string[],
|
|
164
|
+
options: { seen?: boolean } = {},
|
|
159
165
|
): Promise<{ delivered: number }> {
|
|
160
166
|
return this.request<{ delivered: number }>(
|
|
161
167
|
"/chat/notifications/delivered",
|
|
168
|
+
{
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: JSON.stringify({ notificationIds, ...(options.seen ? { seen: true } : {}) }),
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The notifications among these the reader has neither read nor been shown
|
|
177
|
+
* in front of them, on any device. Throws on a server without the route
|
|
178
|
+
* (404); the caller raises the alert then, as before.
|
|
179
|
+
*/
|
|
180
|
+
async getUnseenNotificationIds(notificationIds: string[]): Promise<string[]> {
|
|
181
|
+
const answer = await this.request<{ notificationIds?: unknown }>(
|
|
182
|
+
"/chat/notifications/unseen",
|
|
162
183
|
{
|
|
163
184
|
method: "POST",
|
|
164
185
|
body: JSON.stringify({ notificationIds }),
|
|
165
186
|
},
|
|
166
187
|
);
|
|
188
|
+
return Array.isArray(answer?.notificationIds)
|
|
189
|
+
? answer.notificationIds.filter((id): id is string => typeof id === "string")
|
|
190
|
+
: notificationIds;
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
async openDirectChannel(target: {
|
|
@@ -327,6 +351,10 @@ export class CloudChatApi {
|
|
|
327
351
|
return this.options.socket.subscribeChatNotifications(listener);
|
|
328
352
|
}
|
|
329
353
|
|
|
354
|
+
subscribeChannelState(listener: (state: ChatChannelState) => void): () => void {
|
|
355
|
+
return this.options.socket.subscribeChatChannelState(listener);
|
|
356
|
+
}
|
|
357
|
+
|
|
330
358
|
subscribePresence(listener: ChatPresenceListener): () => void {
|
|
331
359
|
return this.options.socket.subscribeChatPresence(listener);
|
|
332
360
|
}
|
package/src/api-client/errors.ts
CHANGED
|
@@ -75,12 +75,22 @@ export function parseApiErrorMessage(body: string): string {
|
|
|
75
75
|
(value): value is string =>
|
|
76
76
|
typeof value === "string" && value.trim().length > 0,
|
|
77
77
|
);
|
|
78
|
-
return parts.join(" ") || body;
|
|
78
|
+
return parts.join(" ") || validationErrorText(parsed) || body;
|
|
79
79
|
} catch {
|
|
80
80
|
return htmlErrorTitle(body) ?? body;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A request the API's schema refused comes back as the values it was sent
|
|
86
|
+
* (`{"type":"validation","on":"query","found":{...}}`), which is no message to
|
|
87
|
+
* show; it is said in words instead.
|
|
88
|
+
*/
|
|
89
|
+
function validationErrorText(parsed: Record<string, unknown>): string | null {
|
|
90
|
+
if (parsed.type !== "validation") return null;
|
|
91
|
+
return parsed.on === "query" ? "Gloom Cloud did not accept an option or filter value." : "Gloom Cloud did not accept the request.";
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
/** A JSON error body as an object, or undefined for any other body. */
|
|
85
95
|
export function parseApiErrorDetails(body: string): Readonly<Record<string, unknown>> | undefined {
|
|
86
96
|
try {
|
|
@@ -30,6 +30,22 @@ export interface FuturesContractTermsPayload {
|
|
|
30
30
|
inDelivery: boolean
|
|
31
31
|
/** The newest exchange settlement confirmed for the contract, with its session. */
|
|
32
32
|
settlement: { price: number; date: string } | null
|
|
33
|
+
/**
|
|
34
|
+
* For a continuous symbol (CL=F) or a front generic: the nearest contract
|
|
35
|
+
* still trading when the symbol has rolled past it to the most active one,
|
|
36
|
+
* with its last trade; null when it prices the nearest itself. Absent from
|
|
37
|
+
* servers that do not send it.
|
|
38
|
+
*/
|
|
39
|
+
nearest?: FuturesNearestContract | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The nearest contract still trading, which a continuous symbol has rolled past (CLX26 under CL=F on Dec). */
|
|
43
|
+
interface FuturesNearestContract {
|
|
44
|
+
contract: string
|
|
45
|
+
contractSymbol: string
|
|
46
|
+
/** YYYY-MM */
|
|
47
|
+
deliveryMonth: string
|
|
48
|
+
lastTrade: string | null
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/** Gloom Cloud futures prices retain provider denomination and explicit quote units. */
|
package/src/api-client/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
parseCloudLayoutEntry,
|
|
13
13
|
parseCloudLayoutList,
|
|
14
14
|
} from "../layout-marketplace/cloud";
|
|
15
|
-
import type
|
|
15
|
+
import { SyncRevisionConflictError, type SyncSnapshot } from "../sync/types";
|
|
16
16
|
import { withDeadline } from "../utils/async-deadline";
|
|
17
17
|
import { CloudASKGApi } from "./askg";
|
|
18
18
|
import { CloudAuthApi } from "./auth";
|
|
@@ -60,7 +60,8 @@ export type {
|
|
|
60
60
|
/** Server-side caps for `/assist/command`; enforced here so a 422 is never sent. */
|
|
61
61
|
const ASSIST_QUERY_MAX_LENGTH = 200;
|
|
62
62
|
const ASSIST_COMMAND_LIMIT = 150;
|
|
63
|
-
|
|
63
|
+
/** The futures curve's root list is the longest a command sends: 76 since GLO-783. */
|
|
64
|
+
export const ASSIST_ARG_OPTION_LIMIT = 128;
|
|
64
65
|
const ASSIST_ARG_OPTION_VALUE_MAX_LENGTH = 40;
|
|
65
66
|
const ASSIST_ARG_OPTION_LABEL_MAX_LENGTH = 60;
|
|
66
67
|
const ASSIST_REQUEST_TIMEOUT_MS = 6_000;
|
|
@@ -450,14 +451,24 @@ class GloomApiClient {
|
|
|
450
451
|
return this.request<CloudSyncSnapshotResponse>("/sync/snapshot", { method: "GET" });
|
|
451
452
|
}
|
|
452
453
|
|
|
454
|
+
/**
|
|
455
|
+
* `baseRevision` goes out as If-Match: the server ignores the body field and
|
|
456
|
+
* would replace a snapshot another device wrote since this one pulled. With
|
|
457
|
+
* the header it refuses that write (409), raised as SyncRevisionConflictError
|
|
458
|
+
* so the controller pulls again instead.
|
|
459
|
+
*/
|
|
453
460
|
async putSyncSnapshot(snapshot: SyncSnapshot, options?: { baseRevision?: number | null }): Promise<CloudSyncPushResponse> {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
baseRevision:
|
|
459
|
-
|
|
460
|
-
|
|
461
|
+
const baseRevision = options?.baseRevision ?? null;
|
|
462
|
+
try {
|
|
463
|
+
return await this.request<CloudSyncPushResponse>("/sync/snapshot", {
|
|
464
|
+
method: "PUT",
|
|
465
|
+
...(baseRevision !== null ? { headers: { "If-Match": String(baseRevision) } } : {}),
|
|
466
|
+
body: JSON.stringify({ snapshot, baseRevision }),
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error instanceof ApiRequestError && error.status === 409) throw new SyncRevisionConflictError(error.message);
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
461
472
|
}
|
|
462
473
|
|
|
463
474
|
async getMarketplaceLayout(
|
|
@@ -636,6 +647,7 @@ class GloomApiClient {
|
|
|
636
647
|
getChatState = this.chat.getState.bind(this.chat);
|
|
637
648
|
updateChatChannelState = this.chat.updateChannelState.bind(this.chat);
|
|
638
649
|
markChatNotificationsDelivered = this.chat.markNotificationsDelivered.bind(this.chat);
|
|
650
|
+
getUnseenChatNotificationIds = this.chat.getUnseenNotificationIds.bind(this.chat);
|
|
639
651
|
openDirectChannel = this.chat.openDirectChannel.bind(this.chat);
|
|
640
652
|
openGroupChannel = this.chat.openGroupChannel.bind(this.chat);
|
|
641
653
|
getMessages = this.chat.getMessages.bind(this.chat);
|
|
@@ -648,6 +660,7 @@ class GloomApiClient {
|
|
|
648
660
|
subscribeChatReactions = this.chat.subscribeReactions.bind(this.chat);
|
|
649
661
|
connectChannel = this.chat.connectChannel.bind(this.chat);
|
|
650
662
|
subscribeChatNotifications = this.chat.subscribeNotifications.bind(this.chat);
|
|
663
|
+
subscribeChatChannelState = this.chat.subscribeChannelState.bind(this.chat);
|
|
651
664
|
subscribeChatPresence = this.chat.subscribePresence.bind(this.chat);
|
|
652
665
|
getChatDiscordLink = this.chat.getDiscordLink.bind(this.chat);
|
|
653
666
|
unlinkChatDiscord = this.chat.unlinkDiscord.bind(this.chat);
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ChatAccountType,
|
|
3
3
|
ChatAttachment,
|
|
4
4
|
ChatChannel,
|
|
5
|
+
ChatChannelState,
|
|
5
6
|
ChatDiscordLink,
|
|
6
7
|
ChatMessage,
|
|
7
8
|
ChatMessageOrigin,
|
|
@@ -191,6 +192,26 @@ export function normalizeChatNotification(
|
|
|
191
192
|
};
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/** A `chat.channel_state` frame, or null when it is not one. */
|
|
196
|
+
export function normalizeChatChannelStateFrame(data: unknown): ChatChannelState | null {
|
|
197
|
+
if (!data || typeof data !== "object") return null;
|
|
198
|
+
const state = data as Partial<ChatChannelState>;
|
|
199
|
+
if (
|
|
200
|
+
typeof state.channelId !== "string"
|
|
201
|
+
|| typeof state.notificationsEnabled !== "boolean"
|
|
202
|
+
|| typeof state.unreadCount !== "number"
|
|
203
|
+
|| (state.lastReadMessageId !== null && typeof state.lastReadMessageId !== "string")
|
|
204
|
+
) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
channelId: state.channelId,
|
|
209
|
+
notificationsEnabled: state.notificationsEnabled,
|
|
210
|
+
lastReadMessageId: state.lastReadMessageId,
|
|
211
|
+
unreadCount: state.unreadCount,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
194
215
|
export function normalizeChatChannel(
|
|
195
216
|
channel: ChatChannel,
|
|
196
217
|
fallbackKind: ChatChannel["kind"] = "public",
|
package/src/api-client/socket.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TapeFeedEvent } from "./tape";
|
|
2
2
|
import type {
|
|
3
3
|
AuthUser,
|
|
4
|
+
ChatChannelState,
|
|
4
5
|
ChatMessage,
|
|
5
6
|
ChatNotification,
|
|
6
7
|
ChatReactionsUpdate,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
TeamNotification,
|
|
12
13
|
} from "./types";
|
|
13
14
|
import {
|
|
15
|
+
normalizeChatChannelStateFrame,
|
|
14
16
|
normalizeChatMessage,
|
|
15
17
|
normalizeChatNotification,
|
|
16
18
|
normalizeChatReactions,
|
|
@@ -42,6 +44,7 @@ const cloudApiLog = debugLog.createLogger("cloud-api");
|
|
|
42
44
|
|
|
43
45
|
type ChannelListener = (message: ChatMessage) => void;
|
|
44
46
|
type ChatNotificationListener = (notification: ChatNotification) => void;
|
|
47
|
+
type ChatChannelStateListener = (state: ChatChannelState) => void;
|
|
45
48
|
type ChatPresenceListener = (onlineCount: number) => void;
|
|
46
49
|
type ChatReactionsListener = (update: ChatReactionsUpdate) => void;
|
|
47
50
|
type TeamNotificationListener = (notification: TeamNotification) => void;
|
|
@@ -108,6 +111,8 @@ export class CloudApiSocket {
|
|
|
108
111
|
private readonly channelListeners = new Map<string, Set<ChannelListener>>();
|
|
109
112
|
private readonly chatNotificationListeners =
|
|
110
113
|
new Set<ChatNotificationListener>();
|
|
114
|
+
private readonly chatChannelStateListeners =
|
|
115
|
+
new Set<ChatChannelStateListener>();
|
|
111
116
|
private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
|
|
112
117
|
private readonly chatReactionsListeners = new Set<ChatReactionsListener>();
|
|
113
118
|
private readonly teamNotificationListeners =
|
|
@@ -249,6 +254,17 @@ export class CloudApiSocket {
|
|
|
249
254
|
};
|
|
250
255
|
}
|
|
251
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The reader's read state or mute changed on another device or window. Like
|
|
259
|
+
* notifications, it never opens the socket by itself.
|
|
260
|
+
*/
|
|
261
|
+
subscribeChatChannelState(listener: ChatChannelStateListener): () => void {
|
|
262
|
+
this.chatChannelStateListeners.add(listener);
|
|
263
|
+
return () => {
|
|
264
|
+
this.chatChannelStateListeners.delete(listener);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
252
268
|
/** A message's reactions changed in a channel this socket reads. */
|
|
253
269
|
subscribeChatReactions(listener: ChatReactionsListener): () => void {
|
|
254
270
|
this.chatReactionsListeners.add(listener);
|
|
@@ -511,6 +527,7 @@ export class CloudApiSocket {
|
|
|
511
527
|
this.offeredFeatureListeners.clear();
|
|
512
528
|
this.connectionListeners.clear();
|
|
513
529
|
this.chatNotificationListeners.clear();
|
|
530
|
+
this.chatChannelStateListeners.clear();
|
|
514
531
|
this.chatPresenceListeners.clear();
|
|
515
532
|
this.chatReactionsListeners.clear();
|
|
516
533
|
this.teamNotificationListeners.clear();
|
|
@@ -614,6 +631,16 @@ export class CloudApiSocket {
|
|
|
614
631
|
return;
|
|
615
632
|
}
|
|
616
633
|
|
|
634
|
+
if (parsed?.type === "chat.channel_state") {
|
|
635
|
+
const state = normalizeChatChannelStateFrame(parsed.data);
|
|
636
|
+
if (state) {
|
|
637
|
+
for (const listener of this.chatChannelStateListeners) {
|
|
638
|
+
listener(state);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
617
644
|
if (parsed?.type === "team.notification" && parsed.data) {
|
|
618
645
|
const notification = normalizeTeamNotification(
|
|
619
646
|
parsed.data as TeamNotification,
|
package/src/api-client/types.ts
CHANGED
|
@@ -459,10 +459,12 @@ export interface CloudPricePointPayload {
|
|
|
459
459
|
low?: number;
|
|
460
460
|
close: number;
|
|
461
461
|
volume?: number;
|
|
462
|
-
/** A
|
|
462
|
+
/** A daily bar still forming when served: a coin's UTC day, or the trade date a future is trading. */
|
|
463
463
|
partial?: boolean;
|
|
464
464
|
/** A coin's daily bar whose prices were rebuilt from hourly bars; a day the source left empty has no volume. */
|
|
465
465
|
filled?: boolean;
|
|
466
|
+
/** A future's closed session whose settlement is not confirmed yet: it closes on the last trade. */
|
|
467
|
+
unsettled?: boolean;
|
|
466
468
|
}
|
|
467
469
|
|
|
468
470
|
type CloudEconImpact = "high" | "medium" | "low";
|
|
@@ -107,6 +107,10 @@ export function useAppStartupRuntime({
|
|
|
107
107
|
appLog.info("app activity propagated", { active: appActive });
|
|
108
108
|
}, [appActive]);
|
|
109
109
|
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
chatController.setRaisesBackgroundAlerts(!isDetachedWindow);
|
|
112
|
+
}, [isDetachedWindow]);
|
|
113
|
+
|
|
110
114
|
// Runs for the life of the app: a freeze is only reportable if something
|
|
111
115
|
// was watching the clock while it happened.
|
|
112
116
|
useEffect(() => startMainThreadStallMonitor(), []);
|
|
@@ -24,6 +24,7 @@ import { withCliServices, withMarketData } from "../context";
|
|
|
24
24
|
import { isoDate, parsePositiveInt, requireOneArg, takeFlag, takeOption } from "./command-utils";
|
|
25
25
|
import type { BuiltinCliCommandDef } from "../command-options";
|
|
26
26
|
import { CLI_COMMAND_GROUPS, TABLE_SECTION_OPTION } from "../help";
|
|
27
|
+
import { CLI_EXIT_CODES } from "../exit-codes";
|
|
27
28
|
import {
|
|
28
29
|
formatChangePercentCell,
|
|
29
30
|
formatCountCell,
|
|
@@ -44,7 +45,9 @@ import {
|
|
|
44
45
|
import { getPublishedUsEquityCalendarYears, getPublishedUsEquitySession } from "../../market-data/published-us-sessions";
|
|
45
46
|
import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshness";
|
|
46
47
|
import { isOptionsUnavailableError, listedOptionsAlternative, optionsUnavailableTitle } from "../../market-data/options-alternatives";
|
|
47
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
cliFuturesContract, futuresContractMetrics, fundamentalsReportTables, loadForwardYieldDeclarations, renderFundamentalsReport,
|
|
50
|
+
} from "./ticker";
|
|
48
51
|
import type { FuturesContractTermsPayload } from "../../api-client/futures-curve";
|
|
49
52
|
import { futuresSettleText, isFuturesInstrument } from "../../market-data/futures-contract";
|
|
50
53
|
import {
|
|
@@ -65,6 +68,7 @@ import {
|
|
|
65
68
|
type OptionsChainBasis,
|
|
66
69
|
} from "./options-chain";
|
|
67
70
|
import { missingExpiryMessage } from "../../utils/option-expiry";
|
|
71
|
+
import { declaredDividends } from "../../utils/declared-dividends";
|
|
68
72
|
import {
|
|
69
73
|
DEFAULT_LEAPS_CRITERIA, LEAPS_EXTRA_COLUMNS, LEAPS_USAGE, parseLeapsCriteria, runLeapsScreen, takeLeapsOptions,
|
|
70
74
|
} from "./options-leaps";
|
|
@@ -143,14 +147,14 @@ import { earningsCalendarFailure, earningsChoiceNote, earningsDateLabel, isListi
|
|
|
143
147
|
import { formatBasisPoints, toBasisPoints } from "../../utils/basis-points";
|
|
144
148
|
import { notATickerSymbol, providerMissReason } from "../../sources/provider-errors";
|
|
145
149
|
import { isNotATickerMessage, withListingSuggestion } from "../not-a-ticker";
|
|
146
|
-
import { describeSuggestion, suggestListing } from "../listing-suggestion";
|
|
150
|
+
import { describeSuggestion, suggestListing, suggestUsTicker, type ListingSuggestion } from "../listing-suggestion";
|
|
147
151
|
import { splitSymbolArgs, uniqueSecuritySymbols } from "../symbol-args";
|
|
148
152
|
import { cliPerpetualContract, perpetualContractStats, type PerpetualContractPayload } from "../perpetual-contract";
|
|
149
153
|
import { subMinuteDataMessage } from "../known-gaps";
|
|
150
154
|
import { secRegistrantMismatchMessage, SecRegistrantMismatchError, areDifferentCompanies } from "../../sources/sec-registrant";
|
|
151
155
|
import { isCryptoPairSymbol } from "../../utils/crypto-pair";
|
|
152
156
|
import { looksLikeCurrencyPair } from "../../utils/currency-pair";
|
|
153
|
-
import { isUsListingExchange } from "../../utils/exchanges";
|
|
157
|
+
import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
|
|
154
158
|
import { describeTickerChange, startTickerChangeLookup, tickerChangeLine, type TickerChange } from "../../tickers/ticker-changes";
|
|
155
159
|
import { noSecReportsNotice, nonUsSecListingVenue } from "../../utils/sec";
|
|
156
160
|
import type { MarketContext } from "../types";
|
|
@@ -208,6 +212,8 @@ type QuoteCliRecord = Omit<QuoteBatchResult, "error"> & {
|
|
|
208
212
|
perpetualContract?: PerpetualContractPayload;
|
|
209
213
|
/** The symbol changed: its issuer moved to another symbol, or its holder had another before. */
|
|
210
214
|
tickerChange?: TickerChange;
|
|
215
|
+
/** A bare symbol read on a listing abroad that is one slip from a US ticker: the US ticker it was most likely typed for. */
|
|
216
|
+
usSuggestion?: ListingSuggestion;
|
|
211
217
|
};
|
|
212
218
|
type FinancialsCliData = TickerFinancials & {
|
|
213
219
|
symbol: string;
|
|
@@ -504,6 +510,13 @@ function corporateActionRows(data: CorporateActionsData) {
|
|
|
504
510
|
date: event.exDate,
|
|
505
511
|
detail: currency ? formatDistributionAmount(event.amount, currency) : cleanDecimal(event.amount),
|
|
506
512
|
})),
|
|
513
|
+
// Declared and unpaid, at its ex-date beside the paid rows, never one of them.
|
|
514
|
+
...declaredDividends(data).map((event) => ({
|
|
515
|
+
type: "dividend",
|
|
516
|
+
date: event.exDate,
|
|
517
|
+
detail: `declared ${currency ? formatDistributionAmount(event.amount, currency) : cleanDecimal(event.amount)}`
|
|
518
|
+
+ (event.payDate ? `, payable ${event.payDate}` : ""),
|
|
519
|
+
})),
|
|
507
520
|
...data.splits.map((event) => ({
|
|
508
521
|
type: "split",
|
|
509
522
|
date: event.date,
|
|
@@ -568,7 +581,9 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
|
|
|
568
581
|
// So is whether a symbol changed (FB is now META), for the same lists.
|
|
569
582
|
const changes = new Map(listings.length > MAX_CHOICE_LOOKUPS ? []
|
|
570
583
|
: listings.map((listing) => [listing, startTickerChangeLookup(market.dataProvider, listing.symbol, listing.exchange)] as const));
|
|
571
|
-
|
|
584
|
+
// The service's quote of the moment, not each form's local copy from whenever it was last asked for:
|
|
585
|
+
// BRK.B saved on NYSE, BRK-B and BRK.B:NYSE print one quote (GLO-735). A local copy answers only offline.
|
|
586
|
+
const results = await market.dataProvider.getQuotesBatch(targets, { forceRefresh: ctx.cliOptions.refresh, revalidate: true });
|
|
572
587
|
const listingOf = (result: QuoteBatchResult, index: number) => listings[targets.indexOf(result.target)] ?? listings[index]!;
|
|
573
588
|
const asked = results.map(listingOf);
|
|
574
589
|
const request = { command: commandName, noun: "quote" };
|
|
@@ -595,6 +610,11 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
|
|
|
595
610
|
const perpetualContracts = await Promise.all(answers.map(({ result }) => commandName === "quote" && result.quote
|
|
596
611
|
? cliPerpetualContract(result.quote)
|
|
597
612
|
: null));
|
|
613
|
+
// A bare symbol quoted on a listing abroad (APPL, Aptus Pharma on BSE) that is one slip from a US ticker (AAPL) says so.
|
|
614
|
+
const usSuggestions = await Promise.all(answers.map(({ result, listing, notes }) => {
|
|
615
|
+
const venue = result.quote && !listing.saved ? bareListingNote(listing, result.quote) : null;
|
|
616
|
+
return venue && notes.includes(venue) ? suggestUsTicker(listing.symbol, market) : null;
|
|
617
|
+
}));
|
|
598
618
|
// Each row names its listing by key (SAN:EPA) and company, so the table needs no line above it.
|
|
599
619
|
const data = answers.map(({ result, listing, failure }, index) => ({
|
|
600
620
|
target: result.target,
|
|
@@ -604,6 +624,7 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
|
|
|
604
624
|
quote: result.quote,
|
|
605
625
|
...contracts[index] ? { futuresContract: contracts[index] } : {},
|
|
606
626
|
...perpetualContracts[index] ? { perpetualContract: perpetualContracts[index] } : {},
|
|
627
|
+
...usSuggestions[index] ? { usSuggestion: usSuggestions[index] } : {},
|
|
607
628
|
error: failure?.oneLine ?? errorMessage(result.error),
|
|
608
629
|
...yieldIndexFields(result),
|
|
609
630
|
...tickerChanges[index] ? { tickerChange: tickerChanges[index] } : {},
|
|
@@ -630,15 +651,38 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
|
|
|
630
651
|
freshness: quotesFreshness(data.map((row) => row.quote)),
|
|
631
652
|
}, {
|
|
632
653
|
rows: quoteRows,
|
|
633
|
-
details:
|
|
654
|
+
details: quoteDetailsText,
|
|
634
655
|
columns,
|
|
635
656
|
...(yieldsOnly ? { textColumns: columns.map((column) => column.key === "changePercent" ? { ...column, header: "Chg" } : column) } : {}),
|
|
636
657
|
});
|
|
658
|
+
// The table names each symbol it found nothing for; with none found, the exit status says so too.
|
|
659
|
+
if (data.every((row) => !row.quote)) process.exitCode = CLI_EXIT_CODES.error;
|
|
637
660
|
});
|
|
638
661
|
}
|
|
639
662
|
|
|
640
663
|
/**
|
|
641
|
-
* Under the table
|
|
664
|
+
* Under the table: each futures row's Settle line and Contract block, then the
|
|
665
|
+
* line for a symbol that is not a US ticker though it is one slip from one.
|
|
666
|
+
*/
|
|
667
|
+
function quoteDetailsText(data: QuoteCliRecord[]): string {
|
|
668
|
+
return [quoteFuturesContractText(data), ...data.flatMap((row) => notUsTickerLine(row) ?? [])].filter(Boolean).join("\n\n");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* "APPL is not a US ticker: it is Aptus Pharma Limited on BSE (INR). Did you
|
|
673
|
+
* mean AAPL (Apple Inc.)?", for a symbol read on a listing abroad that is one
|
|
674
|
+
* slip from a US ticker, worded as the not-a-ticker answers word it.
|
|
675
|
+
*/
|
|
676
|
+
function notUsTickerLine({ target, quote, listing, usSuggestion }: QuoteCliRecord): string | null {
|
|
677
|
+
if (!quote || !usSuggestion) return null;
|
|
678
|
+
const venue = canonicalExchange(quote.listingExchangeName || quote.exchangeName);
|
|
679
|
+
const name = quote.name || listing?.name;
|
|
680
|
+
const what = `${name ? `${name} on ` : ""}${venue}${quote.currency ? ` (${quote.currency})` : ""}`;
|
|
681
|
+
return cliStyles.warning(`${target.symbol} is not a US ticker: it is ${what}. Did you mean ${describeSuggestion(usSuggestion)}?`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Each futures row's Settle line and Contract block, worded
|
|
642
686
|
* as `ticker` words them. Several symbols name theirs in the block's title.
|
|
643
687
|
*/
|
|
644
688
|
function quoteFuturesContractText(data: QuoteCliRecord[]): string {
|
|
@@ -842,16 +886,18 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
|
|
|
842
886
|
const flagColumn: CliResultColumn = {
|
|
843
887
|
key: "flag", header: "Flag", format: (value) => typeof value === "string" ? cliStyles.warning(value) : "",
|
|
844
888
|
};
|
|
845
|
-
// Text also marks a bar still forming
|
|
889
|
+
// Text also marks a bar still forming, rebuilt from hourly bars or closing before its settlement; exports keep them as fields.
|
|
846
890
|
const flagTextColumn: CliResultColumn = {
|
|
847
891
|
...flagColumn,
|
|
848
892
|
format: (value, row) => [
|
|
849
893
|
typeof value === "string" ? cliStyles.warning(value) : "",
|
|
850
894
|
row.partial ? cliStyles.muted("partial") : "",
|
|
851
895
|
row.filled ? cliStyles.muted("filled") : "",
|
|
896
|
+
row.unsettled ? cliStyles.muted("unsettled") : "",
|
|
852
897
|
].filter(Boolean).join(" "),
|
|
853
898
|
};
|
|
854
899
|
const marked = data.some((row) => row.partial || row.filled);
|
|
900
|
+
const unsettled = data.some((row) => row.unsettled);
|
|
855
901
|
ctx.printResult({
|
|
856
902
|
data,
|
|
857
903
|
metadata: {
|
|
@@ -880,6 +926,7 @@ async function runHistory(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
|
|
|
880
926
|
...priceColumns,
|
|
881
927
|
flagColumn,
|
|
882
928
|
...marked ? [{ key: "partial", header: "Partial" }, { key: "filled", header: "Filled" }] : [],
|
|
929
|
+
...unsettled ? [{ key: "unsettled", header: "Unsettled" }] : [],
|
|
883
930
|
...volume ? [{ key: "volumeUnit", header: "Volume unit" }, { key: "volumeNote", header: "Volume note" }] : [],
|
|
884
931
|
],
|
|
885
932
|
// Every exported row says what it is a bar of, the currency a price is in and its bar size, so it survives head and concatenation.
|
|
@@ -937,6 +984,10 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
|
|
|
937
984
|
// Market cap, the multiples and the yield at the regular session price, as the text gives them; the quote stays as sent.
|
|
938
985
|
const priced = atRegularSessionPrice(financials);
|
|
939
986
|
const capitalization = selectMarketCapitalization(priced.quote, priced.fundamentals);
|
|
987
|
+
// A forward yield dated ahead names the declared distribution it annualizes, in the text and tables.
|
|
988
|
+
const report = ctx.cliOptions.format === "json" ? data : {
|
|
989
|
+
...data, declaredDividends: await loadForwardYieldDeclarations(market.dataProvider, listing.request, financials.fundamentals),
|
|
990
|
+
};
|
|
940
991
|
ctx.printResult({
|
|
941
992
|
// JSON reads a multiple over a loss as null with its reason, never as a number.
|
|
942
993
|
data: {
|
|
@@ -951,8 +1002,8 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
|
|
|
951
1002
|
freshness,
|
|
952
1003
|
...(choice.length > 0 ? { warnings: choice } : {}),
|
|
953
1004
|
}, {
|
|
954
|
-
text: () => renderFundamentalsReport(
|
|
955
|
-
...(tabular ? { tables: selectTables(fundamentalsReportTables(
|
|
1005
|
+
text: () => renderFundamentalsReport(report, view),
|
|
1006
|
+
...(tabular ? { tables: selectTables(fundamentalsReportTables(report, view, freshness)) } : {}),
|
|
956
1007
|
exportIdentity: { symbolOrSeries: listing.key },
|
|
957
1008
|
});
|
|
958
1009
|
return;
|
|
@@ -26,7 +26,7 @@ import { indexRootFor, indexRootTryLine } from "../index-roots";
|
|
|
26
26
|
import { knownGapMessage, settingsPointer } from "../known-gaps";
|
|
27
27
|
import { instrumentTypeLabel } from "../../tickers/instrument-kind";
|
|
28
28
|
import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
|
|
29
|
-
import { groupByCompany } from "../../tickers/company-name";
|
|
29
|
+
import { groupByCompany, sameCompany } from "../../tickers/company-name";
|
|
30
30
|
import { findCatalogSeries, renderSeriesMatches, type SeriesMatch } from "../series-search";
|
|
31
31
|
import { centralBankCiting, centralBankNamed, type CentralBank } from "../../utils/central-banks";
|
|
32
32
|
import { createPaneCatalog } from "../pane-functions/discovery";
|
|
@@ -120,7 +120,7 @@ export async function searchCandidatesForCli({
|
|
|
120
120
|
const quote = BARE_SYMBOL.test(symbol) && candidates.some((candidate) => candidate.symbol.toUpperCase() === symbol)
|
|
121
121
|
? await (quoted ?? loadSearchedQuote(symbol, savedExchange, dataProvider))
|
|
122
122
|
: null;
|
|
123
|
-
return withQuotedNames(symbol, withPrimaryListingFirst(symbol, candidates, quote), quote).slice(0, totalLimit);
|
|
123
|
+
return withQuotedNames(symbol, withHomeListingPrimary(symbol, withPrimaryListingFirst(symbol, candidates, quote)), quote).slice(0, totalLimit);
|
|
124
124
|
} catch {
|
|
125
125
|
return [];
|
|
126
126
|
}
|
|
@@ -217,6 +217,47 @@ function withPrimaryListingFirst(
|
|
|
217
217
|
));
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/** Venues that only quote shares listed elsewhere: the OTC tiers, Frankfurt's open market and Germany's regional exchanges. */
|
|
221
|
+
const SECONDARY_VENUES = new Set(["OTC", "PNK", "OTCQX", "OTCQB", "FWB2", "MUNICH", "XSTU", "XDUS", "XBER", "XHAM", "HANOVER"]);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* How far a listing is from being its company's own line: 0 a plain one, 1 a
|
|
225
|
+
* line that only quotes shares listed elsewhere (a regional venue, OTC, a
|
|
226
|
+
* Milan 4MSFT or London 0XXX code), 2 a depositary receipt.
|
|
227
|
+
*/
|
|
228
|
+
function listingDistance(candidate: TickerSearchCandidate): 0 | 1 | 2 {
|
|
229
|
+
if (/depositary|^(?:ADR|ADS|GDR|CDR|BDR)$/i.test(candidate.instrumentType ?? "")) return 2;
|
|
230
|
+
return SECONDARY_VENUES.has(candidateExchange(candidate)) || /\d/.test(candidate.symbol) && /[A-Za-z]/.test(candidate.symbol) ? 1 : 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A company searched by name lists its home line as the primary listing. A
|
|
235
|
+
* search row names no home exchange or volume, so a company's lines go by
|
|
236
|
+
* how far each is from being its own line (`listingDistance`), then by the
|
|
237
|
+
* search score the service gives the listings people look up most. The
|
|
238
|
+
* listing search put first, often the one-off cross-listing whose short name
|
|
239
|
+
* matched the query (LUFTHANSA on Budapest), gives way only to a closer line,
|
|
240
|
+
* or to one searched at least twice as often, or any scored line when it has
|
|
241
|
+
* no score. A listing searched by its own symbol is not moved.
|
|
242
|
+
*/
|
|
243
|
+
function withHomeListingPrimary(symbol: string, candidates: TickerSearchCandidate[]): TickerSearchCandidate[] {
|
|
244
|
+
const primary = candidates.find((candidate) => candidate.category === "Primary Listing");
|
|
245
|
+
if (!primary || primary.kind !== "search" || primary.symbol.toUpperCase() === symbol) return candidates;
|
|
246
|
+
const company = resolveSearchName(primary);
|
|
247
|
+
const lines = candidates.filter((candidate) => candidate.kind === "search" && candidate.category !== "Funds & Derivatives"
|
|
248
|
+
&& (candidate.instrumentClass === "equity" || candidate.instrumentClass === "other") && sameCompany(resolveSearchName(candidate), company));
|
|
249
|
+
const score = (candidate: TickerSearchCandidate) => candidate.result?.popularity ?? candidate.popularity ?? null;
|
|
250
|
+
const [home] = [...lines].sort((a, b) => listingDistance(a) - listingDistance(b) || (score(b) ?? -1) - (score(a) ?? -1) || candidates.indexOf(a) - candidates.indexOf(b));
|
|
251
|
+
if (!home || home === primary) return candidates;
|
|
252
|
+
const closer = listingDistance(home) < listingDistance(primary);
|
|
253
|
+
const [homeScore, primaryScore] = [score(home), score(primary)];
|
|
254
|
+
const moreSearched = listingDistance(home) === listingDistance(primary) && homeScore != null && (primaryScore == null || homeScore >= primaryScore * 2);
|
|
255
|
+
if (!closer && !moreSearched) return candidates;
|
|
256
|
+
const rest = candidates.filter((candidate) => candidate !== home);
|
|
257
|
+
const at = rest.indexOf(primary);
|
|
258
|
+
return [...rest.slice(0, at), { ...home, category: "Primary Listing" as const }, { ...primary, category: "Other Listings" as const }, ...rest.slice(at + 1)];
|
|
259
|
+
}
|
|
260
|
+
|
|
220
261
|
/** The candidate on the venue the quote prices; the US exchanges share one symbol, so a quote on any of them is its one US line. */
|
|
221
262
|
function quotedCandidate(candidates: readonly TickerSearchCandidate[], quote: Quote | null): TickerSearchCandidate | undefined {
|
|
222
263
|
const quoted = canonicalExchange(quote?.listingExchangeName || quote?.exchangeName);
|