gloomberb 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -10,6 +10,7 @@ import {
10
10
  import { currentFxClosureStart } from "../../utils/fx-market-hours";
11
11
  import { zonedDateKey } from "../../utils/zoned-date-time";
12
12
  import type { ReportTime } from "../../utils/utc-time";
13
+ import type { QuoteIndexSession } from "../../types/financials";
13
14
 
14
15
  /** Where the venues behind a quote report stand, as a report says it after the delay. */
15
16
  type ReportMarketState = "open" | "pre-market" | "after-hours" | "closed";
@@ -27,6 +28,8 @@ export interface SessionObservation {
27
28
  exchange: string;
28
29
  marketState?: string;
29
30
  time: ReportTime | null;
31
+ /** The regular sessions an index quote carries, for a publisher the app has no calendar for. */
32
+ calendar?: QuoteIndexSession;
30
33
  }
31
34
 
32
35
  interface VenueSession {
@@ -35,16 +38,19 @@ interface VenueSession {
35
38
  /** The latest regular session that has closed: its local date and open. */
36
39
  last?: { date: string; open: number | null };
37
40
  timeZone?: string;
41
+ /** The sessions are the quote's own, not a calendar of the venue. */
42
+ quoted?: true;
38
43
  }
39
44
 
40
45
  const CLOSED_STATES = new Set(["CLOSED", "PREPRE", "POSTPOST"]);
46
+ const US_EXCHANGE_TIME_ZONE = "America/New_York";
41
47
 
42
48
  /**
43
49
  * A venue's session now, from its calendar (weekends and published holidays),
44
50
  * or from the state its source reports when the app has no calendar for it,
45
51
  * such as an index publisher or a futures exchange. Null when neither says.
46
52
  */
47
- function venueSession(exchange: string, marketState: string | undefined, now: number): VenueSession | null {
53
+ function venueSession(exchange: string, marketState: string | undefined, now: number, calendar?: QuoteIndexSession): VenueSession | null {
48
54
  if (isAlwaysOpenExchange(exchange)) return { state: "always" };
49
55
  const next = nextRegularSessionOpen(exchange, now);
50
56
  if (next) {
@@ -61,10 +67,24 @@ function venueSession(exchange: string, marketState: string | undefined, now: nu
61
67
  return { state: "closed", next, last, timeZone };
62
68
  }
63
69
  if (marketState === "REGULAR") return { state: "open" };
64
- if (marketState === "PRE") return { state: "pre-market" };
65
- if (marketState === "POST") return { state: "after-hours" };
66
- if (marketState && CLOSED_STATES.has(marketState)) return { state: "closed" };
67
- return null;
70
+ const stated = marketState === "PRE" ? "pre-market" : marketState === "POST" ? "after-hours" : marketState && CLOSED_STATES.has(marketState) ? "closed" : null;
71
+ if (!stated) return null;
72
+ return calendar ? { state: stated, ...quotedSessions(calendar, now) } : { state: stated };
73
+ }
74
+
75
+ /**
76
+ * An index publisher's sessions (SNP, DJI, NASDAQ GIDS) as its quote dates
77
+ * them, read like a venue's calendar: the open still to come, and the latest
78
+ * regular session that has closed.
79
+ */
80
+ function quotedSessions({ timeZone, current, regular }: QuoteIndexSession, now: number): Pick<VenueSession, "next" | "last" | "timeZone" | "quoted"> {
81
+ const closed = [...regular, current].filter((window) => window.end <= now).sort((left, right) => right.end - left.end)[0];
82
+ return {
83
+ timeZone,
84
+ quoted: true,
85
+ ...(current.start > now ? { next: { date: zonedDateKey(current.start, timeZone), open: current.start } } : {}),
86
+ ...(closed ? { last: { date: zonedDateKey(closed.start, timeZone), open: closed.start } } : {}),
87
+ };
68
88
  }
69
89
 
70
90
  /** The venue spot FX pairs trade on. It has no session calendar here; its week is in `fx-market-hours`. */
@@ -101,9 +121,52 @@ function closeDateOf(observation: SessionObservation, session: VenueSession, now
101
121
  function closeMarketOf(exchange: string, session: VenueSession): string | null {
102
122
  if (exchange === FX_VENUE) return "FX";
103
123
  if (!session.last || !session.timeZone) return null;
124
+ // An index publisher's name is no market: a session dated in New York is the US's, any other is a bare date.
125
+ if (session.quoted) return session.timeZone === US_EXCHANGE_TIME_ZONE ? "US" : null;
104
126
  return isUsListingExchange(exchange) ? "US" : exchange;
105
127
  }
106
128
 
129
+ /** One market's session behind a report that mixes markets: its local date, and whether that session has closed. */
130
+ export interface VenueSessionDate {
131
+ /** `US` for the US listing venues, which share one calendar, the venue's code elsewhere (`XETRA`, `EPA`). */
132
+ market: string;
133
+ /** The session's local date, `YYYY-MM-DD`. */
134
+ date: string;
135
+ /** The newest row on that market is its latest close. */
136
+ close: boolean;
137
+ }
138
+
139
+ /**
140
+ * Each market's session, for a report whose rows trade on more than one
141
+ * (a US line beside Xetra and Paris ones): the newest row of each, dated in
142
+ * its venue's own day, in the order the markets first appear. Empty when the
143
+ * rows share one market, or fewer than two can be placed.
144
+ */
145
+ export function reportVenueSessionDates(observations: readonly SessionObservation[], now: number): VenueSessionDate[] {
146
+ const newestByMarket = new Map<string, { observation: SessionObservation & { time: ReportTime }; session: VenueSession }>();
147
+ for (const observation of observations) {
148
+ const exchange = canonicalExchange(observation.exchange);
149
+ const time = observation.time;
150
+ if (!exchange || !time) continue;
151
+ const session = venueSession(exchange, observation.marketState, now);
152
+ if (!session || session.state === "always") continue;
153
+ const market = exchange === FX_VENUE ? "FX" : isUsListingExchange(exchange) ? "US" : exchange;
154
+ const current = newestByMarket.get(market);
155
+ if (!current || time.time > current.observation.time.time) newestByMarket.set(market, { observation: { ...observation, time }, session });
156
+ }
157
+ if (newestByMarket.size < 2) return [];
158
+ return [...newestByMarket].map(([market, { observation, session }]) => {
159
+ // A venue in its session whose newest row is from an earlier day has not printed today: that row is its last close.
160
+ const exchange = canonicalExchange(observation.exchange);
161
+ const latestClose = session.state === "open" && session.timeZone ? latestRegularSessionClose(exchange, now) : null;
162
+ const last = latestClose ? { date: latestClose.date, open: latestRegularSessionOpen(exchange, latestClose.close) } : session.last;
163
+ const closeDate = closeDateOf(observation, { ...session, last }, now);
164
+ const date = closeDate
165
+ ?? (session.timeZone ? zonedDateKey(observation.time.time, session.timeZone) : new Date(observation.time.time).toISOString().slice(0, 10));
166
+ return { market, date, close: closeDate != null };
167
+ });
168
+ }
169
+
107
170
  /**
108
171
  * Where the markets behind a quote report stand now, and whether the newest
109
172
  * observation is a close, and whose. Null market when the venues disagree
@@ -120,7 +183,7 @@ export function reportMarketSession(
120
183
  const exchange = canonicalExchange(observation.exchange);
121
184
  if (!exchange) continue;
122
185
  const key = `${exchange}:${observation.marketState ?? ""}`;
123
- const session = sessions.get(key) ?? venueSession(exchange, observation.marketState, now);
186
+ const session = sessions.get(key) ?? venueSession(exchange, observation.marketState, now, observation.calendar);
124
187
  if (!session) continue;
125
188
  sessions.set(key, session);
126
189
  if (observation.time && (!newest || observation.time.time > (newest.observation.time?.time ?? -Infinity))) {
@@ -12,6 +12,7 @@ import { normalizeTickerInput } from "../../tickers/search";
12
12
  import { slugifyName } from "../../utils/slugify";
13
13
  import { ListingArgError, listingReadNotes, resolveCliListing, usListingsOnlyMessage } from "../listing-arg";
14
14
  import { listingAbroadVenue } from "../../utils/sec";
15
+ import { splitSymbolArgs, stripStrayEdges } from "../symbol-args";
15
16
  import type { MarketContext } from "../types";
16
17
  import {
17
18
  buildPaneFunctionLookup,
@@ -135,10 +136,11 @@ export async function applyListingArgument(
135
136
  }
136
137
  const exchange = typeof args.options.exchange === "string" ? args.options.exchange : undefined;
137
138
  const { exchange: _exchange, ...options } = args.options;
138
- const typed = takesOneTicker ? [args.arg] : args.arg.split(/[,\s]+/).filter(Boolean);
139
+ const typed = takesOneTicker ? [args.arg] : splitSymbolArgs([args.arg]);
139
140
  if (typed.length === 0) return args;
140
141
  let listings = await Promise.all(typed.map((raw) => resolveCliListing(raw, exchange, context, { ownExchangeWins: typed.length > 1 })));
141
- const notes: string[] = [];
142
+ // A separator stuck to the end of a pasted list (`UPST;`) is not part of a symbol; it is named.
143
+ const notes: string[] = takesOneTicker ? [] : stripStrayEdges(args.arg).notes;
142
144
  const usOnly = headless?.usListingsOnly;
143
145
  const abroad = usOnly ? listings.filter((listing) => listingAbroadVenue(listing.key) != null) : [];
144
146
  if (usOnly && abroad.length > 0) {
@@ -936,6 +936,8 @@ export async function renderDesktopShot({
936
936
  watermark,
937
937
  statusLine = false,
938
938
  fitRows = false,
939
+ fitReader = false,
940
+ maxHeightCells,
939
941
  options,
940
942
  captureImage = true,
941
943
  onStalledReport,
@@ -953,6 +955,10 @@ export async function renderDesktopShot({
953
955
  statusLine?: boolean;
954
956
  /** No `--height` was asked for: a pane that fits its rows (FUT) grows to show them all. */
955
957
  fitRows?: boolean;
958
+ /** A pane whose reader has a page open grows to show all of its text (a report's, which saves no image). */
959
+ fitReader?: boolean;
960
+ /** How tall a pane that fits its rows or its reader may grow, in cells, where the height limit of a saved image does not apply. */
961
+ maxHeightCells?: number;
956
962
  options: Record<string, string | true>;
957
963
  captureImage?: boolean;
958
964
  /**
@@ -999,7 +1005,14 @@ export async function renderDesktopShot({
999
1005
  loadModel,
1000
1006
  );
1001
1007
  if (statusLine && captureImage) payload.statusLine = true;
1002
- if (fitRows && resolved.pane.shotFitsRows) payload.maxHeightCells = shotMaxHeightCells(scale ?? 1);
1008
+ const fitRoles = [
1009
+ ...(fitRows && resolved.pane.shotFitsRows ? ["data-table-body-scroll"] : []),
1010
+ ...(fitReader && resolved.pane.shotFitsReader ? ["detail-scroll-body"] : []),
1011
+ ];
1012
+ if (fitRoles.length > 0) {
1013
+ payload.maxHeightCells = maxHeightCells ?? shotMaxHeightCells(scale ?? 1);
1014
+ payload.fitRoles = fitRoles;
1015
+ }
1003
1016
  // Loads while the page renders.
1004
1017
  const report = payload.statusLine ? reportShotFreshness(resolved, loadModel) : undefined;
1005
1018
  // The same load says whether the report is complete, with or without a status line.
@@ -6,7 +6,11 @@ export interface ScrollBoxMetrics {
6
6
  scrollLeft: number;
7
7
  scrollWidth: number;
8
8
  clientWidth: number;
9
- /** The box draws a scrollbar on that axis, which says it overflows even when the measures cannot. */
9
+ /**
10
+ * The box draws a scrollbar on that axis, which says it overflows even when the measures cannot:
11
+ * a table's body, which draws only the rows near the viewport. A plain scroll box draws one by
12
+ * default and holds all its content, so its measures say it.
13
+ */
10
14
  scrollbarY: boolean;
11
15
  scrollbarX: boolean;
12
16
  }
@@ -1,11 +1,49 @@
1
+ /** The quote marks a pasted list wraps its entries in: "AAPL", 'MSFT', “NVDA”. */
2
+ const QUOTE_MARKS = "\"'`\u2018\u2019\u201C\u201D";
3
+ const QUOTE_EDGES = new RegExp(`^[${QUOTE_MARKS}]+|[${QUOTE_MARKS}]+$`, "g");
4
+
1
5
  /**
2
6
  * Symbols given as command arguments, each of which may hold several split by
3
7
  * commas, semicolons, spaces, tabs or newlines (a pasted list), with the blanks
4
- * dropped and the typed case kept. Every command that takes a symbol list splits
5
- * its arguments here, and `not-a-ticker` reads the typed spelling back from them.
8
+ * dropped, the quote marks around each entry removed and the typed case kept.
9
+ * Every command that takes a symbol list splits its arguments here, and
10
+ * `not-a-ticker` reads the typed spelling back from them.
6
11
  */
7
12
  export function splitSymbolArgs(args: readonly string[]): string[] {
8
- return args.flatMap((arg) => arg.replace(/^\uFEFF/, "").split(/[\s,;]+/)).filter(Boolean);
13
+ return args
14
+ .flatMap((arg) => arg.replace(/^\uFEFF/, "").split(/[\s,;]+/))
15
+ .map((entry) => entry.replace(QUOTE_EDGES, ""))
16
+ .filter(Boolean);
17
+ }
18
+
19
+ /** The list separators in `text`, each once: `,;` for a stray `;,;`. */
20
+ function separatorsIn(text: string): string {
21
+ return [...new Set([...text].filter((char) => char === "," || char === ";"))].join("");
22
+ }
23
+
24
+ /**
25
+ * A pasted list, or one cell of a file, without the separators and quote marks
26
+ * stuck to its ends (`UPST;`, `"GME",`). The separators it dropped are named
27
+ * (`Ignored the stray ";" after UPST.`) rather than swallowed, and never reach
28
+ * a listing search as part of a symbol, which would ask for `UPST;`.
29
+ */
30
+ export function stripStrayEdges(text: string): { text: string; notes: string[] } {
31
+ const junk = `[\\s,;${QUOTE_MARKS}]`;
32
+ const leading = text.match(new RegExp(`^${junk}+`))?.[0] ?? "";
33
+ const rest = text.slice(leading.length);
34
+ const trailing = rest.match(new RegExp(`${junk}+$`))?.[0] ?? "";
35
+ const inner = rest.slice(0, rest.length - trailing.length);
36
+ const entries = splitSymbolArgs([inner]);
37
+ const [first, last] = [entries[0], entries.at(-1)];
38
+ const before = separatorsIn(leading);
39
+ const after = separatorsIn(trailing);
40
+ const notes = !first
41
+ ? (before || after ? [`Ignored the stray "${separatorsIn(leading + trailing)}".`] : [])
42
+ : [
43
+ ...before ? [`Ignored the stray "${before}" before ${first}.`] : [],
44
+ ...after ? [`Ignored the stray "${after}" after ${last}.`] : [],
45
+ ];
46
+ return { text: inner, notes };
9
47
  }
10
48
 
11
49
  /** How a symbol is compared for repeats: AAPL, aapl and $AAPL are one. */
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "fs/promises";
2
2
  import { fstatSync } from "fs";
3
- import { splitSymbolArgs, uniqueSymbols } from "./symbol-args";
3
+ import { splitSymbolArgs, stripStrayEdges, uniqueSymbols } from "./symbol-args";
4
4
 
5
5
  /** Header cells that name a spreadsheet's symbol column, compared without case, spaces or punctuation. */
6
6
  const SYMBOL_HEADERS = new Set(["symbol", "symbols", "ticker", "tickers", "code", "stockcode", "securitycode", "instrument"]);
@@ -129,13 +129,25 @@ export class SymbolListError extends Error {
129
129
  /**
130
130
  * The symbols a command was given: as arguments, from `--file`, and from
131
131
  * standard input when an argument is `-` or nothing else was given and input
132
- * is piped in. Repeats are dropped.
132
+ * is piped in. Repeats are dropped. A separator stuck to a symbol in a file, or
133
+ * left on the end of what was typed (`UPST;`), is dropped with the quote marks
134
+ * around it, and each is named in `strays` so the command can say so.
133
135
  */
134
- export async function readSymbolList(source: SymbolListSource): Promise<{ symbols: string[]; repeated: number }> {
136
+ export async function readSymbolList(source: SymbolListSource): Promise<{ symbols: string[]; repeated: number; strays: string[] }> {
135
137
  const readStdin = source.readStdin ?? (() => Bun.stdin.text());
136
138
  const wantsStdin = source.args.includes("-") || source.file === "-";
137
- const typed = splitSymbolArgs(source.args.filter((arg) => arg !== "-"));
139
+ const typedArgs = source.args.filter((arg) => arg !== "-");
140
+ const typed = splitSymbolArgs(typedArgs);
138
141
  const collected = [...typed];
142
+ // A separator left on the end of what was typed: `UPST;`, or a list that ends in a comma.
143
+ const strays = new Set<string>(stripStrayEdges(typedArgs.join(" ")).notes);
144
+ const addCells = (cells: readonly string[]) => {
145
+ for (const cell of cells) {
146
+ const cleaned = stripStrayEdges(cell);
147
+ cleaned.notes.forEach((note) => strays.add(note));
148
+ collected.push(cleaned.text);
149
+ }
150
+ };
139
151
  if (source.file != null && source.file !== "-") {
140
152
  if (!source.file.trim() || source.file.startsWith("--")) throw new SymbolListError("--file needs a path, such as --file symbols.csv.");
141
153
  let text: string;
@@ -145,10 +157,10 @@ export async function readSymbolList(source: SymbolListSource): Promise<{ symbol
145
157
  const code = (error as NodeJS.ErrnoException).code;
146
158
  throw new SymbolListError(code === "ENOENT" ? `No file at ${source.file}.` : `Could not read ${source.file}: ${error instanceof Error ? error.message : String(error)}`);
147
159
  }
148
- collected.push(...parseSymbolText(text));
160
+ addCells(parseSymbolText(text));
149
161
  }
150
162
  if (wantsStdin || (typed.length === 0 && source.file == null && (source.stdinPiped ?? stdinIsPiped()))) {
151
- collected.push(...parseSymbolText(await readStdin()));
163
+ addCells(parseSymbolText(await readStdin()));
152
164
  }
153
- return uniqueSymbols(collected);
165
+ return { ...uniqueSymbols(collected), strays: [...strays] };
154
166
  }
@@ -0,0 +1,70 @@
1
+ /** A function the catalog finds for a word typed as a command (`comps`, `peers`). */
2
+ export interface CatalogCommandHit {
3
+ code: string;
4
+ label: string;
5
+ /** What the function takes after its code: `ticker`, `tickers`, `free-text`, `none`... */
6
+ argKind: string | null;
7
+ argPlaceholder: string | null;
8
+ /** It has a `--peers` switch that adds a symbol's competitors (RV). */
9
+ peers: boolean;
10
+ }
11
+
12
+ const TICKER_ARGUMENTS = new Set(["ticker", "tickers", "symbol-list", "ticker-list"]);
13
+
14
+ /** A company name typed where a ticker goes (`duolingo`, `"coca cola"`): lowercase and longer than a ticker, or several words. */
15
+ function nameLike(arg: string): boolean {
16
+ return /\s/.test(arg.trim()) || /^[a-z]{6,}$/.test(arg.trim());
17
+ }
18
+
19
+ function shellWord(arg: string): string {
20
+ return /^[\w.:,=%+@/$^-]+$/.test(arg) ? arg : JSON.stringify(arg);
21
+ }
22
+
23
+ /**
24
+ * The command that runs `hit` with what the reader typed after the unknown
25
+ * word: their tickers (`gloomberb fn RV DUOL --peers`, the switch added when
26
+ * one symbol meets a function that picks its peers), or the function's
27
+ * placeholder and a search when they typed a company's name.
28
+ */
29
+ function catalogHitCommand(hit: CatalogCommandHit, args: readonly string[]): { command: string; search: string | null } {
30
+ const typed = args.filter((arg) => arg.trim() && !arg.startsWith("-"));
31
+ const base = `gloomberb fn ${hit.code}`;
32
+ if (hit.argKind === "none") return { command: base, search: null };
33
+ const placeholder = hit.argPlaceholder ?? hit.argKind;
34
+ if (!hit.argKind || !TICKER_ARGUMENTS.has(hit.argKind)) {
35
+ const argument = typed.length > 0 ? typed.map(shellWord).join(" ") : placeholder ? `<${placeholder}>` : "";
36
+ return { command: [base, argument].filter(Boolean).join(" "), search: null };
37
+ }
38
+ const names = typed.filter(nameLike);
39
+ const single = typed.length === 1;
40
+ const peers = hit.peers && single ? " --peers" : "";
41
+ if (names.length > 0) {
42
+ return { command: `${base} <${single ? "ticker" : placeholder ?? "tickers"}>${peers}`, search: `gloomberb search ${names.map(shellWord).join(" ")}` };
43
+ }
44
+ return { command: `${base} ${typed.length > 0 ? typed.map(shellWord).join(" ") : `<${placeholder ?? "ticker"}>`}${peers}`, search: null };
45
+ }
46
+
47
+ /**
48
+ * What an unknown command word is answered with when it names something the
49
+ * catalog has (`peers`, `comps`): one function as a command to run, several
50
+ * as a list, with the catalog search for more. Null when the catalog has
51
+ * nothing for it.
52
+ */
53
+ export function catalogPointer(token: string, hits: readonly CatalogCommandHit[], args: readonly string[]): string | null {
54
+ if (hits.length === 0) return null;
55
+ const word = /^[\w.:-]+$/.test(token) ? token : JSON.stringify(token);
56
+ const more = `gloomberb catalog ${word}`;
57
+ if (hits.length === 1) {
58
+ const { command, search } = catalogHitCommand(hits[0]!, args);
59
+ return `Try \`${command}\` (${hits[0]!.label})${search ? `; \`${search}\` finds the ticker` : ""}. \`${more}\` lists more.`;
60
+ }
61
+ const commands = hits.map((hit) => catalogHitCommand(hit, args));
62
+ const width = Math.max(...commands.map(({ command }) => command.length));
63
+ const searches = [...new Set(commands.flatMap(({ search }) => search ?? []))];
64
+ return [
65
+ "Closest functions:",
66
+ ...commands.map(({ command }, index) => ` ${command.padEnd(width)} ${hits[index]!.label}`),
67
+ ...searches.map((search) => `Find the ticker: ${search}`),
68
+ `Search the catalog: ${more}`,
69
+ ].join("\n");
70
+ }
@@ -29,27 +29,57 @@ export function resolveNativeSurfaceVisibleRect(
29
29
  ): CellRect | null {
30
30
  if (!renderable) return null;
31
31
 
32
+ const cached = visibleRectCache.get(renderable);
33
+ if (cached && cached.terminalWidth === terminalWidth && cached.terminalHeight === terminalHeight && ancestorChainUnchanged(renderable, cached.chain)) {
34
+ return cached.visible;
35
+ }
36
+ const chain: Array<CellRect & { visible: boolean }> = [];
37
+ let current: NativeSurfaceRenderableNode | null = renderable;
38
+ const seen = new Set<NativeSurfaceRenderableNode>();
39
+ let depth = 0;
32
40
  let visible: CellRect = {
33
41
  x: 0,
34
42
  y: 0,
35
43
  width: terminalWidth,
36
44
  height: terminalHeight,
37
45
  };
38
- let current: NativeSurfaceRenderableNode | null = renderable;
39
- const seen = new Set<NativeSurfaceRenderableNode>();
40
- let depth = 0;
41
-
42
46
  while (current && !seen.has(current)) {
43
47
  if (depth >= MAX_RENDERABLE_ANCESTOR_DEPTH) return null;
44
48
  seen.add(current);
45
49
  depth += 1;
46
50
  if (current.visible === false) return null;
47
-
48
- const nextVisible = intersectCellRects(visible, getRenderableCellRect(current));
51
+ const rect = getRenderableCellRect(current);
52
+ const nextVisible = intersectCellRects(visible, rect);
49
53
  if (!nextVisible) return null;
54
+ chain.push({ ...rect, visible: true });
50
55
  visible = nextVisible;
51
56
  current = current.parent;
52
57
  }
53
-
58
+ visibleRectCache.set(renderable, { terminalWidth, terminalHeight, chain, visible });
54
59
  return visible;
55
60
  }
61
+
62
+ function ancestorChainUnchanged(
63
+ renderable: NativeSurfaceRenderableNode,
64
+ chain: ReadonlyArray<CellRect & { visible: boolean }>,
65
+ ): boolean {
66
+ let current: NativeSurfaceRenderableNode | null = renderable;
67
+ let index = 0;
68
+ const seen = new Set<NativeSurfaceRenderableNode>();
69
+ while (current && !seen.has(current)) {
70
+ if (index >= chain.length || index >= MAX_RENDERABLE_ANCESTOR_DEPTH) return false;
71
+ seen.add(current);
72
+ const cached = chain[index]!;
73
+ if (current.visible === false || current.x !== cached.x || current.y !== cached.y || current.width !== cached.width || current.height !== cached.height) return false;
74
+ current = current.parent;
75
+ index += 1;
76
+ }
77
+ return index === chain.length;
78
+ }
79
+
80
+ const visibleRectCache = new WeakMap<NativeSurfaceRenderableNode, {
81
+ terminalWidth: number;
82
+ terminalHeight: number;
83
+ chain: Array<CellRect & { visible: boolean }>;
84
+ visible: CellRect;
85
+ }>();