gloomberb 0.18.2 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +106 -5
  3. package/src/api-client/data.ts +15 -0
  4. package/src/api-client/index.ts +2 -0
  5. package/src/api-client/request.ts +5 -3
  6. package/src/api-client/telemetry.ts +17 -0
  7. package/src/app/global-shortcuts.ts +1 -16
  8. package/src/app/runtime/update.ts +28 -8
  9. package/src/architecture/pane-tabs-allowlist.json +65 -0
  10. package/src/cli/commands/market.ts +73 -33
  11. package/src/cli/commands/ticker.ts +82 -25
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +8 -0
  14. package/src/cli/issuer-membership.ts +103 -0
  15. package/src/cli/listing-arg.ts +15 -2
  16. package/src/cli/name-entries.ts +51 -12
  17. package/src/cli/pane-functions/capabilities.ts +2 -0
  18. package/src/cli/pane-functions/catalog.ts +16 -0
  19. package/src/cli/pane-functions/dom.ts +2 -1
  20. package/src/cli/pane-functions/headless.ts +25 -6
  21. package/src/cli/pane-functions/index.ts +64 -5
  22. package/src/cli/pane-functions/resolver.ts +19 -5
  23. package/src/cli/report-tables.ts +136 -18
  24. package/src/cli/result.ts +12 -4
  25. package/src/cli/symbol-list.ts +47 -11
  26. package/src/components/chart/composite/composite-chart.tsx +14 -5
  27. package/src/components/chart/composite/drawing-store.ts +26 -6
  28. package/src/components/chart/composite/interactions.ts +2 -1
  29. package/src/components/chart/composite/panel-surface.tsx +11 -0
  30. package/src/components/chart/composite/types.ts +6 -0
  31. package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
  32. package/src/components/command-bar/routes/root/command-items.ts +32 -6
  33. package/src/components/data-table/view.tsx +23 -3
  34. package/src/components/index.ts +1 -0
  35. package/src/components/layout/detached-pane-shell.tsx +1 -0
  36. package/src/components/layout/floating-pane.tsx +1 -0
  37. package/src/components/layout/header.tsx +228 -55
  38. package/src/components/layout/pane/header.tsx +10 -3
  39. package/src/components/layout/pane/index.tsx +1 -0
  40. package/src/components/layout/pane/pane-tabs.tsx +9 -1
  41. package/src/components/ui/stat-grid.tsx +28 -3
  42. package/src/components/ui/status.tsx +3 -1
  43. package/src/core/app-services.ts +4 -0
  44. package/src/i18n/es.ts +22 -0
  45. package/src/i18n/ja.ts +22 -0
  46. package/src/i18n/ko.ts +22 -0
  47. package/src/i18n/zh-cn.ts +22 -0
  48. package/src/i18n/zh-tw.ts +22 -0
  49. package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
  50. package/src/plugins/builtin/alerts/events.ts +1 -0
  51. package/src/plugins/builtin/alerts/research-rules.ts +1 -0
  52. package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
  53. package/src/plugins/builtin/attention/pane.tsx +16 -14
  54. package/src/plugins/builtin/awards/pane.tsx +6 -6
  55. package/src/plugins/builtin/backtest/export.ts +1 -1
  56. package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
  57. package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
  58. package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
  59. package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
  60. package/src/plugins/builtin/catalysts/client.ts +2 -2
  61. package/src/plugins/builtin/catalysts/pane.tsx +3 -3
  62. package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
  63. package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
  64. package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
  65. package/src/plugins/builtin/company-attention/pane.tsx +13 -11
  66. package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
  67. package/src/plugins/builtin/correlation/headless.ts +2 -1
  68. package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
  69. package/src/plugins/builtin/correlation/settings.ts +1 -0
  70. package/src/plugins/builtin/cot/pane.tsx +4 -3
  71. package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
  72. package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
  73. package/src/plugins/builtin/doe/pane.tsx +1 -1
  74. package/src/plugins/builtin/equity-screener/headless.ts +10 -0
  75. package/src/plugins/builtin/equity-screener/model.ts +28 -0
  76. package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
  77. package/src/plugins/builtin/exposure/pane.tsx +10 -9
  78. package/src/plugins/builtin/gpu/board.tsx +4 -1
  79. package/src/plugins/builtin/gpu/changes.tsx +10 -10
  80. package/src/plugins/builtin/gpu/equities.tsx +4 -1
  81. package/src/plugins/builtin/gpu/history.tsx +4 -2
  82. package/src/plugins/builtin/gpu/pane.tsx +5 -3
  83. package/src/plugins/builtin/holders/client.ts +2 -1
  84. package/src/plugins/builtin/holders/headless.ts +2 -1
  85. package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
  86. package/src/plugins/builtin/insider/base-values.ts +11 -7
  87. package/src/plugins/builtin/insider/client.ts +3 -0
  88. package/src/plugins/builtin/insider/headless.ts +19 -6
  89. package/src/plugins/builtin/insider/index.tsx +17 -8
  90. package/src/plugins/builtin/insider/issuer.ts +34 -4
  91. package/src/plugins/builtin/insider/signals-client.ts +2 -2
  92. package/src/plugins/builtin/insider/table-model.ts +35 -13
  93. package/src/plugins/builtin/insider/table.tsx +5 -2
  94. package/src/plugins/builtin/iv-history/events.ts +145 -0
  95. package/src/plugins/builtin/iv-history/format.ts +3 -0
  96. package/src/plugins/builtin/iv-history/headless.ts +91 -7
  97. package/src/plugins/builtin/jodi/pane.tsx +4 -4
  98. package/src/plugins/builtin/market-movers/client.ts +4 -3
  99. package/src/plugins/builtin/market-movers/screener.ts +4 -3
  100. package/src/plugins/builtin/notes/cli.ts +1 -1
  101. package/src/plugins/builtin/notes/export.ts +17 -6
  102. package/src/plugins/builtin/notes/files.ts +16 -5
  103. package/src/plugins/builtin/options/enrichment-model.ts +10 -9
  104. package/src/plugins/builtin/options/model-spot.ts +70 -4
  105. package/src/plugins/builtin/options/view.tsx +41 -13
  106. package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
  107. package/src/plugins/builtin/options-positioning/headless.ts +11 -3
  108. package/src/plugins/builtin/options-positioning/model.ts +24 -0
  109. package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
  110. package/src/plugins/builtin/options-scenario/client.ts +19 -1
  111. package/src/plugins/builtin/options-scenario/headless.ts +13 -6
  112. package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
  113. package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
  114. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  115. package/src/plugins/builtin/power/client.ts +2 -2
  116. package/src/plugins/builtin/power/pane.tsx +36 -31
  117. package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
  118. package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
  119. package/src/plugins/builtin/seasonality/client.ts +2 -1
  120. package/src/plugins/builtin/sectors/client.ts +4 -3
  121. package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
  122. package/src/plugins/builtin/shared/research-trading.tsx +2 -0
  123. package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
  124. package/src/plugins/builtin/shared/volatility/options.ts +2 -1
  125. package/src/plugins/builtin/social-mentions/client.ts +2 -1
  126. package/src/plugins/builtin/vol-surface/client.ts +23 -3
  127. package/src/plugins/builtin/vol-surface/headless.ts +33 -14
  128. package/src/plugins/builtin/vol-surface/model.ts +30 -10
  129. package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
  130. package/src/plugins/builtin/vol-surface/stored.ts +34 -3
  131. package/src/plugins/builtin/vol-surface/tables.ts +14 -0
  132. package/src/renderers/dom/desktop/controls.tsx +1 -1
  133. package/src/renderers/dom/desktop/query-bar.tsx +2 -0
  134. package/src/renderers/dom/host/tabs.tsx +16 -5
  135. package/src/renderers/dom/host/top-surface.ts +127 -0
  136. package/src/renderers/dom/styles.css +39 -0
  137. package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
  138. package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
  139. package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
  140. package/src/renderers/electrobun/view/news-source.ts +4 -2
  141. package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
  142. package/src/renderers/opentui/start.tsx +7 -0
  143. package/src/sources/gloomberb-cloud/index.ts +7 -0
  144. package/src/sources/provider-router/index.ts +16 -0
  145. package/src/telemetry/crash-reports.ts +4 -0
  146. package/src/telemetry/update-failures-node.ts +59 -0
  147. package/src/telemetry/update-failures.ts +307 -0
  148. package/src/tickers/company-name-listing.ts +9 -7
  149. package/src/tickers/company-name.ts +43 -0
  150. package/src/tickers/listing-fate.ts +30 -0
  151. package/src/types/data-provider.ts +6 -0
  152. package/src/types/headless.ts +7 -0
  153. package/src/types/issuer-listings.ts +28 -0
  154. package/src/updater/failure.ts +163 -0
  155. package/src/updater/index.ts +87 -23
  156. package/src/utils/currency-pair.ts +26 -0
  157. package/src/utils/sec.ts +68 -0
@@ -0,0 +1,307 @@
1
+ import type { UpdateFailureReport } from "../api-client";
2
+ import type { UpdateChannel } from "../types/config";
3
+ import type { UpdateFailure } from "../updater/failure";
4
+ import { logFailure } from "../utils/log-failure";
5
+ import { compareSemver } from "../utils/semver";
6
+ import { VERSION } from "../version";
7
+ import { reportCrash, type CrashReportContext } from "./crash-reports";
8
+
9
+ /**
10
+ * Failed update steps, sent to error tracking through the crash reporter (and
11
+ * so behind the same off switch). A report is the step, a fixed code, the
12
+ * versions, the channel, the platform and how the app was installed: never the
13
+ * error's text, which can hold a path, a user name or a signed URL.
14
+ *
15
+ * The process that runs the update reports it: the terminal app, and the
16
+ * desktop app's Bun process. The desktop view and the web install no host, so
17
+ * the same failure is never sent twice. One report per step, code and app
18
+ * version a day is enough to see a stuck updater; the hourly check that keeps
19
+ * failing offline adds nothing after the first.
20
+ *
21
+ * The desktop host also keeps what an install was about to do, so the next
22
+ * launch can tell an install that never took, or an update downloaded and
23
+ * then quit without, from one that worked (`reviewLastUpdateAttempt`).
24
+ */
25
+
26
+ /**
27
+ * Where the running app sits. macOS installs replace the app bundle in place,
28
+ * which only works from a folder the user can write: not from a disk image,
29
+ * and not from the read-only copy App Translocation runs a quarantined app from.
30
+ */
31
+ export type UpdateInstallKind =
32
+ | "desktop-applications"
33
+ | "desktop-translocated"
34
+ | "desktop-volume"
35
+ | "desktop-elsewhere"
36
+ /** Windows and Linux: the app folder the installer created. */
37
+ | "desktop-app-data"
38
+ | "terminal-binary"
39
+ | "terminal-managed"
40
+ | "terminal-package-manager"
41
+ | "terminal-source"
42
+ | "unknown";
43
+
44
+ /** One update the app downloaded or started installing, and when. */
45
+ export interface UpdateAttempt {
46
+ from: string;
47
+ to: string;
48
+ channel: UpdateChannel;
49
+ at: number;
50
+ }
51
+
52
+ /** What the host keeps on disk between launches. */
53
+ export interface UpdateReportState {
54
+ /** When each step, code and app version was last reported, in ms. */
55
+ sent: Record<string, number>;
56
+ /** A downloaded update waiting for the user's Restart. */
57
+ staged?: UpdateAttempt;
58
+ /** An install the user started; the next launch says whether it took. */
59
+ applying?: UpdateAttempt;
60
+ }
61
+
62
+ export interface UpdateFailureReporterHost {
63
+ installKind: UpdateInstallKind;
64
+ /** The running version; defaults to this build's. */
65
+ appVersion?: string;
66
+ /** `process.platform` and `process.arch`. */
67
+ platform: string;
68
+ arch: string;
69
+ load(): UpdateReportState;
70
+ save(state: UpdateReportState): void;
71
+ now?(): number;
72
+ /** Defaults to the crash reporter. */
73
+ send?(report: UpdateFailureReport): void;
74
+ }
75
+
76
+ export interface UpdateFailureContext {
77
+ /** The version the update was going to, when one was known. */
78
+ targetVersion?: string | null;
79
+ channel?: UpdateChannel | null;
80
+ }
81
+
82
+ const DAY_MS = 24 * 60 * 60 * 1000;
83
+ /** Older attempts say nothing about this launch: the user installed by hand, or reinstalled. */
84
+ const ATTEMPT_MAX_AGE_MS = 30 * DAY_MS;
85
+ /**
86
+ * The install quits the app and opens the new one as soon as it is gone. A new
87
+ * version first seen this long after is one the user opened again by hand.
88
+ */
89
+ const RELAUNCH_GRACE_MS = 10 * 60 * 1000;
90
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,60})?$/;
91
+
92
+ let host: UpdateFailureReporterHost | null = null;
93
+
94
+ function cleanVersion(value: string | null | undefined): string | undefined {
95
+ const version = value?.trim().replace(/^v/, "");
96
+ return version && VERSION_PATTERN.test(version) ? version : undefined;
97
+ }
98
+
99
+ function reportPlatform(platform: string): UpdateFailureReport["platform"] {
100
+ if (platform === "darwin") return "macos";
101
+ if (platform === "win32") return "windows";
102
+ if (platform === "linux") return "linux";
103
+ return "other";
104
+ }
105
+
106
+ function reportArch(arch: string): UpdateFailureReport["arch"] {
107
+ return arch === "arm64" || arch === "x64" ? arch : "other";
108
+ }
109
+
110
+ /**
111
+ * The report, from fixed values only: anything that is not a version, a
112
+ * known channel, platform or install kind is left out or named `other`.
113
+ */
114
+ function buildUpdateFailureReport(
115
+ failure: UpdateFailure,
116
+ fields: UpdateFailureContext & {
117
+ appVersion: string;
118
+ platform: string;
119
+ arch: string;
120
+ installKind: UpdateInstallKind;
121
+ },
122
+ ): UpdateFailureReport {
123
+ const targetVersion = cleanVersion(fields.targetVersion);
124
+ return {
125
+ phase: failure.phase,
126
+ code: failure.code,
127
+ appVersion: cleanVersion(fields.appVersion) ?? "unknown",
128
+ ...(targetVersion ? { targetVersion } : {}),
129
+ channel: fields.channel === "nightly" ? "nightly" : "stable",
130
+ platform: reportPlatform(fields.platform),
131
+ arch: reportArch(fields.arch),
132
+ installKind: fields.installKind,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The report as the crash reporter sends it. Today's server keeps the error's
138
+ * type, message, plugin and pane: the message is the step and code, so error
139
+ * tracking groups one issue per failure, and the pane holds the rest as
140
+ * `key=value` pairs. The `update` field carries the same values for a server
141
+ * that reads it.
142
+ */
143
+ function updateFailureCrashReport(report: UpdateFailureReport): {
144
+ error: { name: string; message: string };
145
+ context: CrashReportContext;
146
+ } {
147
+ const details = [
148
+ `to=${report.targetVersion ?? "none"}`,
149
+ `channel=${report.channel}`,
150
+ `platform=${report.platform}-${report.arch}`,
151
+ `install=${report.installKind}`,
152
+ ].join(" ");
153
+ return {
154
+ // A plain object, not an Error: it has no stack, so no file path goes along.
155
+ error: { name: "UpdateFailed", message: `${report.phase}: ${report.code}` },
156
+ context: { kind: "plugin", plugin: "updater", pane: details, update: report },
157
+ };
158
+ }
159
+
160
+ function sendThroughCrashReporter(report: UpdateFailureReport): void {
161
+ const { error, context } = updateFailureCrashReport(report);
162
+ reportCrash(error, context);
163
+ }
164
+
165
+ /**
166
+ * Takes the day's slot for `key`, or says it is used: one report per step,
167
+ * code and version a day. Drops what is older than a day while at it.
168
+ */
169
+ function takeReportSlot(state: UpdateReportState, key: string, now: number): boolean {
170
+ for (const [entry, at] of Object.entries(state.sent)) {
171
+ if (!(now - at < DAY_MS) || at > now) delete state.sent[entry];
172
+ }
173
+ if (state.sent[key] !== undefined) return false;
174
+ state.sent[key] = now;
175
+ return true;
176
+ }
177
+
178
+ function loadState(current: UpdateFailureReporterHost): UpdateReportState {
179
+ try {
180
+ const state = current.load();
181
+ return { ...state, sent: { ...state.sent } };
182
+ } catch (error) {
183
+ logFailure("telemetry-update-failures", "Could not read update report state")(error);
184
+ return { sent: {} };
185
+ }
186
+ }
187
+
188
+ function saveState(current: UpdateFailureReporterHost, state: UpdateReportState): void {
189
+ try {
190
+ current.save(state);
191
+ } catch (error) {
192
+ logFailure("telemetry-update-failures", "Could not save update report state")(error);
193
+ }
194
+ }
195
+
196
+ function sendIfDue(
197
+ current: UpdateFailureReporterHost,
198
+ state: UpdateReportState,
199
+ failure: UpdateFailure,
200
+ context: UpdateFailureContext,
201
+ now: number,
202
+ ): void {
203
+ const report = buildUpdateFailureReport(failure, {
204
+ ...context,
205
+ appVersion: current.appVersion ?? VERSION,
206
+ platform: current.platform,
207
+ arch: current.arch,
208
+ installKind: current.installKind,
209
+ });
210
+ if (!takeReportSlot(state, `${report.phase}|${report.code}|${report.appVersion}`, now)) return;
211
+ (current.send ?? sendThroughCrashReporter)(report);
212
+ }
213
+
214
+ export function installUpdateFailureReporter(next: UpdateFailureReporterHost): () => void {
215
+ host = next;
216
+ return () => {
217
+ if (host === next) host = null;
218
+ };
219
+ }
220
+
221
+ /** Reports one failed step. Never throws; does nothing where no host is installed. */
222
+ export function reportUpdateFailure(failure: UpdateFailure, context: UpdateFailureContext = {}): void {
223
+ const current = host;
224
+ if (!current) return;
225
+ try {
226
+ const now = current.now?.() ?? Date.now();
227
+ const state = loadState(current);
228
+ // An install that failed in this run is reported here, not again at the next launch.
229
+ if (failure.phase === "apply" || failure.phase === "relaunch") delete state.applying;
230
+ sendIfDue(current, state, failure, context, now);
231
+ saveState(current, state);
232
+ } catch (error) {
233
+ logFailure("telemetry-update-failures", "Could not report update failure")(error);
234
+ }
235
+ }
236
+
237
+ function recordAttempt(field: "staged" | "applying", to: string, channel: UpdateChannel): void {
238
+ const current = host;
239
+ if (!current) return;
240
+ const state = loadState(current);
241
+ const attempt: UpdateAttempt = { from: current.appVersion ?? VERSION, to, channel, at: current.now?.() ?? Date.now() };
242
+ if (field === "applying") delete state.staged;
243
+ state[field] = attempt;
244
+ saveState(current, state);
245
+ }
246
+
247
+ /** A downloaded update is waiting for the user's Restart. */
248
+ export function noteUpdateStaged(to: string, channel: UpdateChannel): void {
249
+ recordAttempt("staged", to, channel);
250
+ }
251
+
252
+ /** The user pressed Restart: the next launch should be `to`. */
253
+ export function noteUpdateApplying(to: string, channel: UpdateChannel): void {
254
+ recordAttempt("applying", to, channel);
255
+ }
256
+
257
+ /** The waiting update was taken back (a channel switch); quitting without it is no longer news. */
258
+ export function forgetStagedUpdate(): void {
259
+ const current = host;
260
+ if (!current) return;
261
+ const state = loadState(current);
262
+ if (!state.staged) return;
263
+ delete state.staged;
264
+ saveState(current, state);
265
+ }
266
+
267
+ /**
268
+ * Run once at launch: what came of the last update this app started.
269
+ *
270
+ * - Restarted into the new version: nothing to report, unless the new version
271
+ * only came up long after, which is the app failing to reopen itself.
272
+ * - Still the old version after an install: it never took.
273
+ * - Still the old version with a download waiting: the app was quit without
274
+ * restarting into it, which the stuck installs may come down to.
275
+ */
276
+ export function reviewLastUpdateAttempt(): void {
277
+ const current = host;
278
+ if (!current) return;
279
+ const currentVersion = current.appVersion ?? VERSION;
280
+ try {
281
+ const state = loadState(current);
282
+ const { applying, staged } = state;
283
+ if (!applying && !staged) return;
284
+ delete state.applying;
285
+ delete state.staged;
286
+ const now = current.now?.() ?? Date.now();
287
+ const fresh = (attempt: UpdateAttempt | undefined) => !!attempt && now - attempt.at < ATTEMPT_MAX_AGE_MS && attempt.at <= now;
288
+ if (applying && fresh(applying)) {
289
+ const context = { targetVersion: applying.to, channel: applying.channel };
290
+ if (applying.from === currentVersion) {
291
+ sendIfDue(current, state, { phase: "apply", code: "not_applied" }, context, now);
292
+ } else if ((compareSemver(currentVersion, applying.to) ?? -1) >= 0 && now - applying.at > RELAUNCH_GRACE_MS) {
293
+ sendIfDue(current, state, { phase: "relaunch", code: "relaunch_missed" }, context, now);
294
+ }
295
+ } else if (staged && fresh(staged) && staged.from === currentVersion) {
296
+ sendIfDue(current, state, { phase: "apply", code: "not_restarted" }, { targetVersion: staged.to, channel: staged.channel }, now);
297
+ }
298
+ saveState(current, state);
299
+ } catch (error) {
300
+ logFailure("telemetry-update-failures", "Could not review the last update")(error);
301
+ }
302
+ }
303
+
304
+ /** Test seam. */
305
+ export function resetUpdateFailureReporterForTests(): void {
306
+ host = null;
307
+ }
@@ -2,7 +2,7 @@ import { logFailure } from "../utils/log-failure";
2
2
  import type { DataProvider } from "../types/data-provider";
3
3
  import type { InstrumentSearchResult } from "../types/instrument";
4
4
  import { canonicalExchange, isUsListingExchange, parsePublicTickerKey } from "../utils/exchanges";
5
- import { companyNameExtraWords, shortCompanyName } from "./company-name";
5
+ import { companyNameMatch, shortCompanyName } from "./company-name";
6
6
  import { LISTING_SEARCH_LIMIT } from "./search";
7
7
  import { classifyInstrumentKind } from "./search/ranking";
8
8
  import { isProviderBusy, type ProviderBusyError } from "../sources/provider-errors";
@@ -28,10 +28,12 @@ export function isCompanyNameInput(input: string): boolean {
28
28
  /**
29
29
  * The listing a company's name stands for when it is typed where a symbol
30
30
  * goes, as an entry of a comma list ("Whirlpool Corporation" is WHR on
31
- * NYSE). Of search's listings whose names have every word of it (the name is
32
- * also searched without its legal form, which can find more), the company
31
+ * NYSE). Of search's listings whose names have every word of it, or a word
32
+ * it shortens ("philip morris intl" is Philip Morris International; the name
33
+ * is also searched without its legal form, which can find more), the company
33
34
  * whose name says no more than the one typed goes first, so a subsidiary that
34
- * shares the word (Whirlpool of India) does not; then its line on a US
35
+ * shares the word (Whirlpool of India) does not, and of those a name matched
36
+ * word for word before one a short form matched; then its line on a US
35
37
  * exchange, then a plain line before a depositary receipt, the shortest
36
38
  * symbol (WHR before WHR-A) and search's order. Contracts, preferred shares
37
39
  * and notes are never the listing. Null when none matches; when none does
@@ -55,10 +57,10 @@ export async function findListingOfCompanyName(
55
57
  const ranked = results.flatMap((result, order) => {
56
58
  const exchange = canonicalExchange(result.exchange === "SMART" ? result.primaryExchange : result.exchange || result.primaryExchange);
57
59
  const type = result.brokerContract?.secType || result.type || "";
58
- const extra = companyNameExtraWords(name, result.name);
59
- if (!exchange || extra == null || classifyInstrumentKind(type) === "derivative" || NOT_COMMON.test(type)
60
+ const match = companyNameMatch(name, result.name);
61
+ if (!exchange || match == null || classifyInstrumentKind(type) === "derivative" || NOT_COMMON.test(type)
60
62
  || parsePublicTickerKey(result.symbol).exchange) return [];
61
- return [{ result, exchange, rank: [extra, isUsListingExchange(exchange) ? 0 : 1, RECEIPT.test(type) ? 1 : 0, result.symbol.length, order] }];
63
+ return [{ result, exchange, rank: [match.extra, match.abbreviated, isUsListingExchange(exchange) ? 0 : 1, RECEIPT.test(type) ? 1 : 0, result.symbol.length, order] }];
62
64
  }).sort((left, right) => {
63
65
  const at = left.rank.findIndex((value, index) => value !== right.rank[index]);
64
66
  return at < 0 ? 0 : left.rank[at]! - right.rank[at]!;
@@ -56,6 +56,49 @@ export function companyNameExtraWords(typed: string, name: string | null | undef
56
56
  return words.size - wanted.size;
57
57
  }
58
58
 
59
+ /**
60
+ * Whether a typed word is a short form of a name's word, as a list writes
61
+ * one: its first letter and the rest in order, at least three letters
62
+ * (`intl` for international, `hldgs` for holdings, `grp` for group).
63
+ */
64
+ function abbreviates(short: string, word: string): boolean {
65
+ if (short.length < 3 || short.length >= word.length || short[0] !== word[0] || !/^\p{L}+$/u.test(short)) return false;
66
+ let at = 0;
67
+ for (const char of word) {
68
+ if (char === short[at]) at += 1;
69
+ if (at === short.length) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /**
75
+ * `companyNameExtraWords` for a name typed with short forms, as a list or a
76
+ * spreadsheet writes it ("philip morris intl" for Philip Morris
77
+ * International Inc.): each typed word is one of the name's, or a short form
78
+ * of another of its words (`abbreviates`). `extra` counts the name's words
79
+ * left unmatched, `abbreviated` the typed words read as short forms, so an
80
+ * exact name ranks before one only a short form reaches. Null when a typed
81
+ * word is in the name neither way, or nothing was typed.
82
+ */
83
+ export function companyNameMatch(typed: string, name: string | null | undefined): { extra: number; abbreviated: number } | null {
84
+ const wanted = companyWords(typed);
85
+ const words = companyWords(name);
86
+ if (wanted.size === 0) return null;
87
+ const matched = new Set<string>();
88
+ let abbreviated = 0;
89
+ for (const word of wanted) {
90
+ if (words.has(word)) {
91
+ matched.add(word);
92
+ continue;
93
+ }
94
+ const long = [...words].find((candidate) => !matched.has(candidate) && !wanted.has(candidate) && abbreviates(word, candidate));
95
+ if (!long) return null;
96
+ matched.add(long);
97
+ abbreviated += 1;
98
+ }
99
+ return { extra: words.size - matched.size, abbreviated };
100
+ }
101
+
59
102
  /** Items grouped by the company their names are, in the order each company first appears. Unnamed items are left out. */
60
103
  export function groupByCompany<T>(items: readonly T[], nameOf: (item: T) => string | null | undefined): T[][] {
61
104
  const groups: T[][] = [];
@@ -235,6 +235,36 @@ export function listingFateNotes(fate: ListingFate, served: { served: boolean; f
235
235
  ].filter((line): line is string => !!line);
236
236
  }
237
237
 
238
+ /**
239
+ * A report's notes and errors beside what became of its symbol, worded as
240
+ * `quote`, `history` and `options` word it: with nothing served, the fate is
241
+ * why ("TWTR: Delisted Oct 27, 2022", "FB now lists ...; Meta Platforms
242
+ * trades as META") and stands in for the errors the empty load raised ("Not
243
+ * a ticker", "No options provider"); with data, `listingFateNotes` beside
244
+ * the errors. A symbol whose company moved, with nothing listed under it now,
245
+ * has nothing of its own to serve and throws `MovedSymbolError`.
246
+ */
247
+ export function listingFateReport(
248
+ fate: ListingFate,
249
+ served: { served: boolean; firstDate?: string | null },
250
+ errors: readonly string[],
251
+ ): { notes: string[]; errors: string[] } {
252
+ if (fate.moved) throw new MovedSymbolError(fate.moved);
253
+ const notes = listingFateNotes(fate, served);
254
+ return { notes, errors: !served.served && notes.length > 0 ? [] : [...errors] };
255
+ }
256
+
257
+ /**
258
+ * A report that served nothing for its symbol (`listingFateReport`): what
259
+ * became of the symbol is the one reason it is empty, said once in place of
260
+ * every table's "No data." and of the load's errors; the errors alone when
261
+ * nothing became of it.
262
+ */
263
+ export function listingFateEmpty(fate: ListingFate, errors: readonly string[]): { empty?: string; errors: string[] } {
264
+ const report = listingFateReport(fate, { served: false }, errors);
265
+ return report.notes.length ? { empty: report.notes.join(" "), errors: report.errors } : { errors: report.errors };
266
+ }
267
+
238
268
  /**
239
269
  * What a report or loader with nothing of its own to serve for a symbol
240
270
  * throws when the symbol's company moved (`ListingFate.moved`): its message
@@ -16,6 +16,7 @@ import type { BrokerContractRef, InstrumentSearchResult } from "./instrument";
16
16
  import type { CachePolicyMap } from "./persistence";
17
17
  import type { CachedQueryHandle } from "../data/cached-query";
18
18
  import type { TickerChanges } from "./ticker-changes";
19
+ import type { IssuerListings } from "./issuer-listings";
19
20
 
20
21
  export type CachedAssetMethod = "getExchangeRate" | "getHolders" | "getAnalystResearch" | "getCorporateActions"
21
22
  | "getOptionsChain" | "getSecFilings" | "getSecFilingDocuments" | "getSecFilingContent" | "getArticleSummary";
@@ -252,6 +253,11 @@ export interface AssetDataProvider {
252
253
  * answer for is left out; null when the source cannot answer a batch at all.
253
254
  */
254
255
  getTickerChangesBatch?(symbols: string[], options?: { signal?: AbortSignal }): Promise<Map<string, TickerChanges | null> | null>;
256
+ /**
257
+ * Every listing of the company a listing belongs to (RCI and RCI.B:TSX are
258
+ * Rogers). Null when the source does not hold the company.
259
+ */
260
+ getIssuerListings?(symbol: string, exchange?: string, options?: { signal?: AbortSignal }): Promise<IssuerListings | null>;
255
261
  /**
256
262
  * Several searches in a few requests, each answered with the rows `search`
257
263
  * gives it from this source (up to `limit`), by query as asked. A query the
@@ -542,6 +542,13 @@ export interface HeadlessPaneDefinition<Shape extends HeadlessPaneShape = Headle
542
542
  discovery?: {
543
543
  id?: string;
544
544
  aliases?: string[];
545
+ /**
546
+ * Words that are no function's code but ask for this function's data
547
+ * (IVOL, IV for implied volatility). They do not open it, since one word
548
+ * can mean several functions: `fn IVOL` answers with the functions it
549
+ * means and the commands that run them, and `catalog ivol` lists them first.
550
+ */
551
+ suggestFor?: string[];
545
552
  intents?: string[];
546
553
  dataRequirements?: string[];
547
554
  limitations?: string[];
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Every listing of one company, as Gloom Cloud's company universe groups
3
+ * them: share classes, a US receipt and its home line, dual listings. RCI on
4
+ * the NYSE and RCI.B on the TSX are both Rogers Communications.
5
+ */
6
+
7
+ /** One listing, written as the service writes it: bare on a US venue (RCI), SYMBOL:EXCHANGE elsewhere (RCI.B:TSX). */
8
+ interface IssuerListing {
9
+ symbol: string;
10
+ /** The canonical venue code. */
11
+ exchange: string;
12
+ name: string;
13
+ /** The company's primary line. */
14
+ primary: boolean;
15
+ }
16
+
17
+ export interface IssuerListings {
18
+ /** The service's key for the company (cik:733099). */
19
+ issuer: string;
20
+ name: string;
21
+ /**
22
+ * Where the company is from, ISO 3166 alpha-2: its primary line's country,
23
+ * or the SEC address of a foreign company whose only lines are in the US.
24
+ */
25
+ country: string;
26
+ /** The primary line first. */
27
+ listings: IssuerListing[];
28
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Why an update step failed, as a fixed phase and code rather than the error
3
+ * text: the header says the reason in a few words, and error tracking gets
4
+ * the same two values, which carry no path, URL or account name.
5
+ */
6
+
7
+ /** The step that failed. `relaunch` is getting back into the app after an install. */
8
+ export type UpdateFailurePhase = "check" | "download" | "verify" | "apply" | "relaunch";
9
+
10
+ const UPDATE_FAILURE_CODES = [
11
+ /** No route to the release server: offline, DNS, refused, TLS. */
12
+ "offline",
13
+ "timeout",
14
+ /** GitHub answered 403 or 429, its limit for requests without an account. */
15
+ "rate_limited",
16
+ "server_error",
17
+ /** The release has no such file (404). */
18
+ "not_found",
19
+ /** Any other refusal from the release server. */
20
+ "http_error",
21
+ /** The release list or manifest did not read as one. */
22
+ "bad_response",
23
+ /** The release has no build for this platform, or none with a checksum. */
24
+ "no_build",
25
+ /** The download stopped before the whole file arrived. */
26
+ "interrupted",
27
+ /** The file arrived whole but does not match its checksum, or does not unpack. */
28
+ "corrupt",
29
+ "permission_denied",
30
+ /** The macOS app runs from where it cannot be replaced: a disk image, App Translocation, another volume. */
31
+ "wrong_location",
32
+ /** The downloaded update and the installed app are on different disks, so the one cannot be moved onto the other. */
33
+ "cross_device",
34
+ "read_only",
35
+ "disk_full",
36
+ /** Another process holds the files being replaced. */
37
+ "file_in_use",
38
+ /** The update downloaded earlier is no longer where the install expects it. */
39
+ "staged_missing",
40
+ /** A restart into the update came back on the old version. */
41
+ "not_applied",
42
+ /** The app quit with a downloaded update waiting and was opened again on the old version. */
43
+ "not_restarted",
44
+ /** The install finished but the app did not come back by itself. */
45
+ "relaunch_missed",
46
+ "unknown",
47
+ ] as const;
48
+
49
+ export type UpdateFailureCode = typeof UPDATE_FAILURE_CODES[number];
50
+
51
+ export interface UpdateFailure {
52
+ phase: UpdateFailurePhase;
53
+ code: UpdateFailureCode;
54
+ }
55
+
56
+ /** What an HTTP status from the release server means for an update. */
57
+ export function httpStatusFailureCode(status: number): UpdateFailureCode {
58
+ if (status === 403 || status === 429) return "rate_limited";
59
+ if (status === 404 || status === 410) return "not_found";
60
+ if (status >= 500) return "server_error";
61
+ return "http_error";
62
+ }
63
+
64
+ const OFFLINE_ERRNO = new Set([
65
+ "ENOTFOUND",
66
+ "EAI_AGAIN",
67
+ "ECONNREFUSED",
68
+ "ENETUNREACH",
69
+ "EHOSTUNREACH",
70
+ "ENETDOWN",
71
+ // Bun's fetch
72
+ "ConnectionRefused",
73
+ "FailedToOpenSocket",
74
+ "UnableToConnect",
75
+ "CERT_HAS_EXPIRED",
76
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
77
+ "SELF_SIGNED_CERT_IN_CHAIN",
78
+ ]);
79
+ const INTERRUPTED_ERRNO = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "ConnectionClosed"]);
80
+
81
+ /**
82
+ * The code for an error a step threw, from its system error code first and its
83
+ * message second. Only the code leaves the machine; the message stays local.
84
+ */
85
+ export function classifyUpdateError(error: unknown): UpdateFailureCode {
86
+ const record = (error && typeof error === "object" ? error : {}) as { code?: unknown; name?: unknown; message?: unknown };
87
+ const message = typeof record.message === "string" ? record.message : typeof error === "string" ? error : "";
88
+ // A system error code, or one named in the message ("Failed to replace app: EACCES: ...").
89
+ const errno = typeof record.code === "string" ? record.code : /\b(E[A-Z]{3,})\b/.exec(message)?.[1] ?? "";
90
+ switch (errno) {
91
+ case "EACCES":
92
+ case "EPERM":
93
+ return "permission_denied";
94
+ case "EROFS":
95
+ return "read_only";
96
+ case "ENOSPC":
97
+ case "EDQUOT":
98
+ return "disk_full";
99
+ case "EBUSY":
100
+ case "ETXTBSY":
101
+ return "file_in_use";
102
+ case "EXDEV":
103
+ return "cross_device";
104
+ case "ETIMEDOUT":
105
+ return "timeout";
106
+ }
107
+ if (OFFLINE_ERRNO.has(errno)) return "offline";
108
+ if (INTERRUPTED_ERRNO.has(errno)) return "interrupted";
109
+ if (record.name === "TimeoutError" || record.name === "AbortError") return "timeout";
110
+
111
+ const status = /\b(?:HTTP|returned|status|failed:?)\s*\(?(\d{3})\b/i.exec(message)?.[1];
112
+ if (status) return httpStatusFailureCode(Number(status));
113
+ if (/checksum mismatch|hash mismatch/i.test(message)) return "corrupt";
114
+ if (/zstd|decompress|gunzip|incorrect header|unexpected end of (?:file|data)/i.test(message)) return "corrupt";
115
+ if (/timed? ?out/i.test(message)) return "timeout";
116
+ if (/rate limit/i.test(message)) return "rate_limited";
117
+ if (/read-only file system/i.test(message)) return "read_only";
118
+ if (/cross-device/i.test(message)) return "cross_device";
119
+ if (/permission denied|operation not permitted|access is denied/i.test(message)) return "permission_denied";
120
+ if (/no space left|disk full/i.test(message)) return "disk_full";
121
+ if (/resource busy|text file busy|being used by another process/i.test(message)) return "file_in_use";
122
+ if (/connection (?:reset|closed|aborted)|socket hang up|premature close|terminated/i.test(message)) return "interrupted";
123
+ if (/unable to connect|fetch failed|failed to fetch|network|getaddrinfo|could not resolve|certificate/i.test(message)) return "offline";
124
+ if (/invalid update\.json|unexpected .*payload|json/i.test(message)) return "bad_response";
125
+ return "unknown";
126
+ }
127
+
128
+ /** When the failure has no more specific reason: what the step was. */
129
+ const PHASE_REASONS: Record<UpdateFailurePhase, string> = {
130
+ check: "check failed",
131
+ download: "download failed",
132
+ verify: "could not verify the download",
133
+ apply: "could not install",
134
+ relaunch: "could not restart",
135
+ };
136
+
137
+ const CODE_REASONS: Partial<Record<UpdateFailureCode, string>> = {
138
+ offline: "no connection",
139
+ timeout: "connection timed out",
140
+ rate_limited: "GitHub rate limit",
141
+ server_error: "release server error",
142
+ not_found: "release server error",
143
+ http_error: "release server error",
144
+ bad_response: "unreadable release data",
145
+ no_build: "no build for this system",
146
+ interrupted: "download interrupted",
147
+ corrupt: "download corrupted",
148
+ permission_denied: "no permission to replace the app",
149
+ wrong_location: "move the app to Applications",
150
+ cross_device: "app and update on different disks",
151
+ read_only: "install folder is read-only",
152
+ disk_full: "disk full",
153
+ file_in_use: "app files in use",
154
+ staged_missing: "downloaded update missing",
155
+ };
156
+
157
+ /**
158
+ * The reason in a few words, in English (the caller translates it), sized
159
+ * for the header: "download interrupted", "no permission to replace the app".
160
+ */
161
+ export function updateFailureReason(failure: UpdateFailure): string {
162
+ return CODE_REASONS[failure.code] ?? PHASE_REASONS[failure.phase];
163
+ }