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
|
@@ -5,6 +5,7 @@ import { findNamedCollection } from "../../portfolio/collection-name";
|
|
|
5
5
|
import { apiClient } from "../../api-client";
|
|
6
6
|
import type { MarketContext } from "../types";
|
|
7
7
|
import type {
|
|
8
|
+
HeadlessArgumentExpansion,
|
|
8
9
|
HeadlessBundleResult,
|
|
9
10
|
HeadlessBundleSection,
|
|
10
11
|
HeadlessPaneColumn,
|
|
@@ -37,6 +38,7 @@ import type { ResolvedSeries } from "../../time-series/types";
|
|
|
37
38
|
import type { PaneFunctionReport } from "./report";
|
|
38
39
|
import type { ResolvedPaneFunction } from "./resolver";
|
|
39
40
|
import { isRecord } from "../../utils/guards";
|
|
41
|
+
import { splitSymbolArgs } from "../symbol-args";
|
|
40
42
|
import { formatUtcTime, localTimeSuffix } from "../../utils/utc-time";
|
|
41
43
|
import { deriveHeadlessFreshness, formatFreshnessLine, staleRowsText, type ReportFreshness } from "./freshness";
|
|
42
44
|
import { accessGateStatus, errorsAccessGate, isProLock } from "./access-gate";
|
|
@@ -74,7 +76,7 @@ function normalizeSymbol(value: string): string {
|
|
|
74
76
|
function normalizeSymbolList(value: string): string[] {
|
|
75
77
|
const symbols: string[] = [];
|
|
76
78
|
const seen = new Set<string>();
|
|
77
|
-
for (const part of
|
|
79
|
+
for (const part of splitSymbolArgs([value])) {
|
|
78
80
|
const symbol = normalizeSymbol(part);
|
|
79
81
|
if (!symbol || seen.has(symbol)) continue;
|
|
80
82
|
seen.add(symbol);
|
|
@@ -162,22 +164,17 @@ async function loadHeadlessPaneModel(
|
|
|
162
164
|
return result;
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
type HeadlessMarketContext = Pick<MarketContext, "config" | "store" | "refresh"> & Partial<Pick<MarketContext, "persistence" | "seriesCapabilities">> & {
|
|
168
|
+
dataProvider: HeadlessPaneContext["marketData"];
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** What a headless loader reads the market, the account and the saved collections through. */
|
|
172
|
+
function createHeadlessPaneContext(
|
|
170
173
|
resolved: ResolvedPaneFunction,
|
|
171
|
-
context:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
signal: AbortSignal = new AbortController().signal,
|
|
176
|
-
): Promise<LoadedHeadlessPaneModel> {
|
|
177
|
-
const definition = resolved.headless;
|
|
178
|
-
if (!definition) throw new Error(`${resolved.token} has no headless pane model.`);
|
|
179
|
-
const args = buildHeadlessPaneLoadArgs(definition, resolved.token, rawArgument, resolved.options);
|
|
180
|
-
const headlessContext: HeadlessPaneContext = {
|
|
174
|
+
context: HeadlessMarketContext,
|
|
175
|
+
signal: AbortSignal,
|
|
176
|
+
): HeadlessPaneContext {
|
|
177
|
+
return {
|
|
181
178
|
marketData: context.dataProvider,
|
|
182
179
|
apiClient,
|
|
183
180
|
config: context.config,
|
|
@@ -203,7 +200,40 @@ export async function loadResolvedHeadlessPaneModel(
|
|
|
203
200
|
return { symbol: parsed.symbol, exchange: parsed.exchange ?? ticker?.metadata.exchange };
|
|
204
201
|
},
|
|
205
202
|
};
|
|
206
|
-
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The argument a function widens before it runs (`expandArgument`, RV's
|
|
207
|
+
* `--peers`), with the settings that say how; null when it runs as typed.
|
|
208
|
+
*/
|
|
209
|
+
export async function expandResolvedArgument(
|
|
210
|
+
resolved: ResolvedPaneFunction,
|
|
211
|
+
context: HeadlessMarketContext,
|
|
212
|
+
rawArgument: string,
|
|
213
|
+
signal: AbortSignal = new AbortController().signal,
|
|
214
|
+
): Promise<HeadlessArgumentExpansion | null> {
|
|
215
|
+
const definition = resolved.headless;
|
|
216
|
+
if (!definition?.expandArgument) return null;
|
|
217
|
+
const args = buildHeadlessPaneLoadArgs(definition, resolved.token, rawArgument, resolved.options);
|
|
218
|
+
const expansion = await definition.expandArgument(args, createHeadlessPaneContext(resolved, context, signal));
|
|
219
|
+
throwIfAborted(signal);
|
|
220
|
+
return expansion;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Loads the same renderer-neutral model used by `fn`.
|
|
225
|
+
* Both reports and screenshot payloads execute this loader.
|
|
226
|
+
*/
|
|
227
|
+
export async function loadResolvedHeadlessPaneModel(
|
|
228
|
+
resolved: ResolvedPaneFunction,
|
|
229
|
+
context: HeadlessMarketContext,
|
|
230
|
+
rawArgument: string,
|
|
231
|
+
signal: AbortSignal = new AbortController().signal,
|
|
232
|
+
): Promise<LoadedHeadlessPaneModel> {
|
|
233
|
+
const definition = resolved.headless;
|
|
234
|
+
if (!definition) throw new Error(`${resolved.token} has no headless pane model.`);
|
|
235
|
+
const args = buildHeadlessPaneLoadArgs(definition, resolved.token, rawArgument, resolved.options);
|
|
236
|
+
const result = await loadHeadlessPaneModel(definition, args, createHeadlessPaneContext(resolved, context, signal));
|
|
207
237
|
return { definition, args, result };
|
|
208
238
|
}
|
|
209
239
|
|
|
@@ -527,6 +557,36 @@ function splitErrors(result: HeadlessPaneResult): { locked: string[]; failed: st
|
|
|
527
557
|
return { locked: errors.filter(isProLock), failed: errors.filter((error) => !isProLock(error)) };
|
|
528
558
|
}
|
|
529
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Why a report with nothing in it is empty, when its pane gave no reason of
|
|
562
|
+
* its own: the free view left it empty (the report carries a Pro line), or the
|
|
563
|
+
* symbols asked for are not covered by the function (or hold nothing for the
|
|
564
|
+
* options chosen). Null when something has
|
|
565
|
+
* rows, a table states its own reason, an error explains it, or no symbol was
|
|
566
|
+
* asked for (a board that is empty says nothing about coverage).
|
|
567
|
+
*/
|
|
568
|
+
function emptyReportReason(
|
|
569
|
+
definition: HeadlessPaneDefinition,
|
|
570
|
+
result: HeadlessPaneResult,
|
|
571
|
+
args: HeadlessPaneLoadArgs,
|
|
572
|
+
token: string | undefined,
|
|
573
|
+
): string | null {
|
|
574
|
+
if (resultRowCount(definition, result) > 0) return null;
|
|
575
|
+
const ownReason = definition.shape === "rows" ? !!(result as HeadlessRowsResult).empty
|
|
576
|
+
: definition.shape === "bundle" && (result as HeadlessBundleResult).sections.some((section) => "empty" in section && !!section.empty);
|
|
577
|
+
const { locked, failed } = splitErrors(result);
|
|
578
|
+
if (ownReason || failed.length) return null;
|
|
579
|
+
if (locked.length) return "Locked: Gloom Pro";
|
|
580
|
+
const symbols = result.symbols ?? args.symbols;
|
|
581
|
+
if (!symbols.length) return null;
|
|
582
|
+
// An option the reader chose (a range, a tab) can leave a covered symbol empty, so coverage is not claimed then.
|
|
583
|
+
const narrowed = definition.options.some((option) => {
|
|
584
|
+
const value = args.options[option.key];
|
|
585
|
+
return value !== undefined && value !== "" && String(value) !== String(option.defaultValue ?? "");
|
|
586
|
+
});
|
|
587
|
+
return narrowed ? `No data for ${symbols.join(", ")} with these options.` : `No data for ${symbols.join(", ")}: not covered${token ? ` by ${token}` : ""}.`;
|
|
588
|
+
}
|
|
589
|
+
|
|
530
590
|
function textNotices(result: HeadlessPaneResult): string[] {
|
|
531
591
|
const notices = result.metadata?.notices;
|
|
532
592
|
if (!Array.isArray(notices)) return [];
|
|
@@ -634,15 +694,20 @@ export function renderHeadlessPaneText(
|
|
|
634
694
|
args: HeadlessPaneLoadArgs,
|
|
635
695
|
fallbackTitle: string,
|
|
636
696
|
freshness: ReportFreshness = deriveHeadlessFreshness(definition, result),
|
|
697
|
+
/** The function's token, which an empty report names as the one that does not cover the symbol. */
|
|
698
|
+
token?: string,
|
|
637
699
|
): string {
|
|
638
700
|
// A long title wraps to a narrow --width like the text under a table; each line stays bold.
|
|
639
701
|
const lines = [...wrapToTerminal(reportTitle(definition, args, result, fallbackTitle)).split("\n").map(cliStyles.bold), ""];
|
|
640
702
|
const notices = textNotices(result);
|
|
641
703
|
if (notices.length) lines.push(...notices, "");
|
|
642
|
-
|
|
704
|
+
const reason = emptyReportReason(definition, result, args, token);
|
|
705
|
+
// Said once, not under the heading of each empty table.
|
|
706
|
+
if (reason) lines.push(reason);
|
|
707
|
+
else switch (definition.shape) {
|
|
643
708
|
case "rows": {
|
|
644
709
|
const rowsResult = result as HeadlessRowsResult;
|
|
645
|
-
lines.push(renderRows(rowsResult.rows, rowsResult.columns, definition.columns, rowsResult.empty));
|
|
710
|
+
lines.push(renderRows(rowsResult.rows, rowsResult.textColumns ?? rowsResult.columns, definition.columns, rowsResult.empty));
|
|
646
711
|
break;
|
|
647
712
|
}
|
|
648
713
|
case "bundle":
|
|
@@ -756,6 +821,7 @@ export async function buildHeadlessFunctionReport(
|
|
|
756
821
|
loaded.args,
|
|
757
822
|
resolved.label,
|
|
758
823
|
freshness,
|
|
824
|
+
resolved.token,
|
|
759
825
|
),
|
|
760
826
|
tables: headlessReportTables(
|
|
761
827
|
loaded.definition,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { extname, isAbsolute, relative, resolve } from "path";
|
|
2
|
-
import type { CliCommandContext } from "../../types/plugin";
|
|
2
|
+
import type { CliCommandContext, HeadlessArgumentExpansion } from "../../types/plugin";
|
|
3
3
|
import type { MarketContext } from "../types";
|
|
4
4
|
import { withMarketData } from "../context";
|
|
5
5
|
import { cliStyles, renderStats, type CliStatEntry } from "../../utils/cli-output";
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
findFunctionCode,
|
|
9
9
|
renderPaneCatalogReport,
|
|
10
10
|
selectPaneCatalogMatches,
|
|
11
|
+
suggestPaneFunctions,
|
|
11
12
|
type PaneFunctionCatalog,
|
|
12
13
|
} from "./catalog";
|
|
13
14
|
import { createPaneCatalog } from "./discovery";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "./options";
|
|
20
21
|
import { applyListingArgument, resolvePaneFunction, UnknownPaneFunctionError, type ResolvedPaneFunction } from "./resolver";
|
|
21
22
|
import { buildFunctionReport } from "./report";
|
|
23
|
+
import { expandResolvedArgument } from "./headless";
|
|
22
24
|
import { defaultScreenshotPath, renderDesktopShot, shotSizeWarnings, shotStatusIssues } from "./screenshot";
|
|
23
25
|
import {
|
|
24
26
|
buildPaneCatalogEntries,
|
|
@@ -29,6 +31,7 @@ import { ListingArgError, listingReadNotes, loadForListing, quotedListingChoiceN
|
|
|
29
31
|
import { knownGapMessage, settingsPointer, topicPointer } from "../known-gaps";
|
|
30
32
|
import { selectReportTables } from "../report-tables";
|
|
31
33
|
import { cliOutputWritten } from "../result";
|
|
34
|
+
import type { CatalogCommandHit } from "../unknown-command";
|
|
32
35
|
import { CLI_EXIT_CODES } from "../exit-codes";
|
|
33
36
|
import {
|
|
34
37
|
GLOSSARY,
|
|
@@ -55,8 +58,14 @@ async function withPaneRuntime<T>(
|
|
|
55
58
|
const context: MarketContext = ctx.cliOptions.refresh ? { ...market, refresh: true } : market;
|
|
56
59
|
const registry = await createPaneCatalog(context, ctx.plugins);
|
|
57
60
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
let parsed = await applyListingArgument(registry, context, parsePaneFunctionArgs(args, ctx.cliOptions), settings.command);
|
|
62
|
+
let resolved = await resolvePaneFunction(registry, context, parsed, settings);
|
|
63
|
+
// A function that widens its argument (RV's --peers) runs, reports and captures the widened one.
|
|
64
|
+
const expansion = await withPersistedCloudSession(context, () => expandResolvedArgument(resolved, context, parsed.arg));
|
|
65
|
+
if (expansion) {
|
|
66
|
+
parsed = { ...parsed, arg: expansion.argument };
|
|
67
|
+
resolved = withExpansion(await resolvePaneFunction(registry, context, parsed, settings), expansion);
|
|
68
|
+
}
|
|
60
69
|
return await run({ parsed, context, registry, resolved });
|
|
61
70
|
} finally {
|
|
62
71
|
registry.destroy();
|
|
@@ -64,6 +73,15 @@ async function withPaneRuntime<T>(
|
|
|
64
73
|
});
|
|
65
74
|
}
|
|
66
75
|
|
|
76
|
+
/** The resolved function with what its expansion adds to the pane: settings, and a title that says how. */
|
|
77
|
+
function withExpansion(resolved: ResolvedPaneFunction, { settings = {}, title }: HeadlessArgumentExpansion): ResolvedPaneFunction {
|
|
78
|
+
return {
|
|
79
|
+
...resolved,
|
|
80
|
+
optionSettings: { ...resolved.optionSettings, ...settings },
|
|
81
|
+
instance: { ...resolved.instance, ...(title ? { title } : {}), settings: { ...resolved.instance.settings, ...settings } },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
67
85
|
/** A word that can be a function code: letters and digits, with the dots and dashes some codes carry. */
|
|
68
86
|
const FUNCTION_CODE_WORD = /^[\w.$-]{1,24}$/;
|
|
69
87
|
|
|
@@ -102,6 +120,42 @@ export async function functionCodeCommand(
|
|
|
102
120
|
}
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
/** How many functions an unknown command word is answered with. */
|
|
124
|
+
const COMMAND_HIT_LIMIT = 3;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The functions the catalog finds for a word typed as a command (`gloomberb
|
|
128
|
+
* comps`): what `catalog <word>` would list first, as many as are close.
|
|
129
|
+
* Empty when the word finds none or the catalog cannot be read.
|
|
130
|
+
*/
|
|
131
|
+
export async function catalogCommandHits(ctx: CliCommandContext, token: string): Promise<CatalogCommandHit[]> {
|
|
132
|
+
const word = token.trim();
|
|
133
|
+
if (!/^[a-z][\w-]{1,30}$/i.test(word)) return [];
|
|
134
|
+
try {
|
|
135
|
+
return await withMarketData(ctx, async (market) => {
|
|
136
|
+
const registry = await createPaneCatalog(market, ctx.plugins);
|
|
137
|
+
try {
|
|
138
|
+
return (await suggestPaneFunctions(registry, market, word)).slice(0, COMMAND_HIT_LIMIT).map((entry) => ({
|
|
139
|
+
code: entry.token,
|
|
140
|
+
label: entry.label,
|
|
141
|
+
argKind: entry.argKind ?? null,
|
|
142
|
+
argPlaceholder: entry.argPlaceholder ?? null,
|
|
143
|
+
peers: entry.capability.options.some((option) => option.key === "peers" && option.type === "boolean"),
|
|
144
|
+
}));
|
|
145
|
+
} finally {
|
|
146
|
+
registry.destroy();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether a list report has no row with data: it is empty, or every row is a name without any. */
|
|
155
|
+
function foundNothing(data: { rowCount: number; unavailableSymbols: readonly string[] }): boolean {
|
|
156
|
+
return data.rowCount === 0 || data.unavailableSymbols.length >= data.rowCount;
|
|
157
|
+
}
|
|
158
|
+
|
|
105
159
|
export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
|
|
106
160
|
await runPaneCliCommand(ctx, async () => {
|
|
107
161
|
await withPaneRuntime(ctx, args, async ({ parsed, context, resolved }) => {
|
|
@@ -161,6 +215,8 @@ export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
|
|
|
161
215
|
text: () => glossary ? `${report.text}\n\n${renderReportGlossary(resolved.token, glossary)}` : report.text,
|
|
162
216
|
...(tabular ? { tables: selectReportTables(report.tables, resolved.tableSection) } : {}),
|
|
163
217
|
});
|
|
218
|
+
// The report is printed as it is; a list that found data for none of its names says so in the exit status too.
|
|
219
|
+
if (resolved.headless?.failsWithoutData && foundNothing(report.data)) process.exitCode = CLI_EXIT_CODES.error;
|
|
164
220
|
}, { strictHeadlessOptions: true, tableSection: true });
|
|
165
221
|
});
|
|
166
222
|
}
|
|
@@ -283,7 +339,7 @@ function runGlossary(term: string, ctx: CliCommandContext): void {
|
|
|
283
339
|
const { exact, partial } = lookupGlossary(term);
|
|
284
340
|
const entries = [...exact, ...partial];
|
|
285
341
|
if (entries.length === 0) {
|
|
286
|
-
ctx.fail(`No glossary entry for "${term}".`, "gloomberb catalog glossary lists every term.");
|
|
342
|
+
ctx.fail(`No glossary entry for "${term}".`, knownGapMessage(term) ?? "gloomberb catalog glossary lists every term.");
|
|
287
343
|
}
|
|
288
344
|
ctx.printResult({ data: entries.map(glossaryRecord) }, { text: () => renderGlossaryEntries(entries).join("\n") });
|
|
289
345
|
}
|
|
@@ -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
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/cli/symbol-args.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
|
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. */
|
package/src/cli/symbol-list.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
160
|
+
addCells(parseSymbolText(text));
|
|
149
161
|
}
|
|
150
162
|
if (wantsStdin || (typed.length === 0 && source.file == null && (source.stdinPiped ?? stdinIsPiped()))) {
|
|
151
|
-
|
|
163
|
+
addCells(parseSymbolText(await readStdin()));
|
|
152
164
|
}
|
|
153
|
-
return uniqueSymbols(collected);
|
|
165
|
+
return { ...uniqueSymbols(collected), strays: [...strays] };
|
|
154
166
|
}
|