gloomberb 0.18.0 → 0.18.1

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 (75) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/market-movers.ts +7 -0
  3. package/src/api-client/types.ts +9 -0
  4. package/src/cli/commands/fred-series.ts +15 -5
  5. package/src/cli/commands/market.ts +9 -5
  6. package/src/cli/commands/overview.ts +31 -6
  7. package/src/cli/commands/ticker.ts +5 -2
  8. package/src/cli/pane-functions/function-help.ts +4 -4
  9. package/src/cli/symbol-list.ts +51 -16
  10. package/src/components/chart/composite/composite-chart.tsx +11 -5
  11. package/src/components/chart/composite/panel-surface.tsx +6 -8
  12. package/src/components/chart/composite/price-axis-labels.tsx +2 -1
  13. package/src/components/chart/composite/types.ts +4 -0
  14. package/src/components/command-bar/workflow/collection-actions.ts +4 -0
  15. package/src/components/onboarding/wizard-positions.ts +8 -0
  16. package/src/docs/snapshot.json +52 -17
  17. package/src/plugins/builtin/analytics/index.tsx +1 -1
  18. package/src/plugins/builtin/analytics/risk-client.ts +44 -11
  19. package/src/plugins/builtin/analytics/risk-fx.ts +61 -0
  20. package/src/plugins/builtin/analytics/risk-headless.ts +10 -1
  21. package/src/plugins/builtin/analytics/risk-model.ts +79 -16
  22. package/src/plugins/builtin/analytics/risk-pane.tsx +8 -2
  23. package/src/plugins/builtin/analytics/risk-test-data.ts +3 -0
  24. package/src/plugins/builtin/cloud/team/pane-sections.tsx +10 -3
  25. package/src/plugins/builtin/cloud/team/pane.tsx +30 -9
  26. package/src/plugins/builtin/custom-view/loader.ts +3 -2
  27. package/src/plugins/builtin/custom-view/pane.tsx +5 -4
  28. package/src/plugins/builtin/custom-view/view-spec.ts +1 -0
  29. package/src/plugins/builtin/econ/calendar-model.ts +3 -2
  30. package/src/plugins/builtin/econ/fred-catalog.ts +6 -2
  31. package/src/plugins/builtin/econ/fred-series-map.ts +12 -1
  32. package/src/plugins/builtin/executives/data.ts +24 -8
  33. package/src/plugins/builtin/executives/index.tsx +2 -18
  34. package/src/plugins/builtin/executives/pane.tsx +34 -10
  35. package/src/plugins/builtin/market-movers/client.ts +2 -2
  36. package/src/plugins/builtin/market-movers/index.tsx +0 -3
  37. package/src/plugins/builtin/market-movers/session-body.tsx +27 -11
  38. package/src/plugins/builtin/market-movers/session.ts +25 -10
  39. package/src/plugins/builtin/news/wire/feed-config.ts +4 -1
  40. package/src/plugins/builtin/news/wire/letters/client.ts +1 -0
  41. package/src/plugins/builtin/news/wire/news/command-bar-search.ts +6 -3
  42. package/src/plugins/builtin/news/wire/news/detail-view.tsx +2 -1
  43. package/src/plugins/builtin/news/wire/news/table.tsx +2 -1
  44. package/src/plugins/builtin/news/wire/rss/feed-xml.ts +6 -1
  45. package/src/plugins/builtin/news/wire/rss/managed-feed.ts +1 -0
  46. package/src/plugins/builtin/notes/cli.ts +33 -8
  47. package/src/plugins/builtin/notes/export.ts +49 -3
  48. package/src/plugins/builtin/notes/files.ts +14 -3
  49. package/src/plugins/builtin/notes/index.tsx +3 -3
  50. package/src/plugins/builtin/notes/quick-notes-pane.tsx +45 -10
  51. package/src/plugins/builtin/notes/store.ts +13 -3
  52. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -7
  53. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +166 -15
  54. package/src/plugins/builtin/portfolio-list/cli/render.ts +35 -5
  55. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +2 -2
  56. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  57. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -3
  58. package/src/plugins/builtin/portfolio-list/summary/index.tsx +10 -1
  59. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +42 -7
  60. package/src/plugins/builtin/shared/broker-reconnect.ts +105 -0
  61. package/src/plugins/builtin/shared/pro-feature-wall.tsx +9 -4
  62. package/src/plugins/builtin/thirteenf/api.ts +4 -1
  63. package/src/plugins/builtin/thirteenf/data.ts +5 -1
  64. package/src/plugins/builtin/thirteenf/headless.ts +3 -1
  65. package/src/plugins/builtin/thirteenf/pane.tsx +8 -3
  66. package/src/portfolio/cost-fx.ts +84 -0
  67. package/src/portfolio/mutations.ts +16 -2
  68. package/src/portfolio/position-metrics.ts +45 -4
  69. package/src/portfolio/position-quantity.ts +43 -0
  70. package/src/sources/gloomberb-cloud/fred-series.ts +27 -2
  71. package/src/sync/core-contributors.ts +1 -0
  72. package/src/team/collections.ts +2 -0
  73. package/src/tickers/metadata.ts +10 -0
  74. package/src/types/ticker.ts +9 -0
  75. package/src/utils/currency-units.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -50,6 +50,13 @@ export interface CloudSessionMoversPayload {
50
50
  phase: CloudSessionPhase;
51
51
  asOf: string;
52
52
  stale?: boolean;
53
+ /**
54
+ * `delayed` for an account without Pro: the list as it was built at least
55
+ * `delayMinutes` before it was served, as old as that account's quotes,
56
+ * and `asOf` that time. Absent on Pro's live list.
57
+ */
58
+ access?: "delayed";
59
+ delayMinutes?: number;
53
60
  items: CloudSessionMoverItem[];
54
61
  }
55
62
 
@@ -1005,6 +1005,13 @@ interface CloudProxyStatementSummaryPayload {
1005
1005
  }
1006
1006
 
1007
1007
  export interface CloudProxyStatementPayload extends CloudProxyStatementSummaryPayload {
1008
+ /**
1009
+ * `preview` for an account without Pro: each executive's total pay from
1010
+ * the latest proxy, with the breakdown, the year before, the pay ratio,
1011
+ * say-on-pay, highlights, key figures and other years null or empty.
1012
+ * Absent on Pro's full statement.
1013
+ */
1014
+ access?: "preview";
1008
1015
  docUrl: string;
1009
1016
  ceo: (CloudExecutiveRowPayload & { priorYearTotal: number | null }) | null;
1010
1017
  namedExecutives: CloudExecutiveRowPayload[];
@@ -1126,6 +1133,8 @@ export interface CloudProxyStatementListPayload {
1126
1133
  shortName: string;
1127
1134
  };
1128
1135
  proxies: CloudProxyStatementSummaryPayload[];
1136
+ /** `preview` for an account without Pro: the latest proxy alone, without the year before or the pay ratio. */
1137
+ access?: "preview";
1129
1138
  }
1130
1139
 
1131
1140
  export interface CloudTranscriptTurnPayload {
@@ -1,4 +1,5 @@
1
1
  import { seriesMoves, type MoveHorizon } from "../../plugins/builtin/shared/move-rank";
2
+ import { seriesNotOnFred } from "../../sources/gloomberb-cloud/fred-series";
2
3
  import { ICE_SPREAD_SERIES } from "../../time-series/ice-credit";
3
4
  import { toBasisPoints } from "../../utils/basis-points";
4
5
  import { parseReportTime } from "../../utils/utc-time";
@@ -138,7 +139,10 @@ export interface FredProvenance {
138
139
  * looking for the figure in FRED's own data knows it is not there yet:
139
140
  * `U.S. Treasury via FRED, published 2026-10-09`. Null for FRED's own.
140
141
  */
141
- export function fredProvenance(observations: ReadonlyArray<{ date: string; publisher?: string; publishedAt?: string }>): FredProvenance | null {
142
+ export function fredProvenance(
143
+ observations: ReadonlyArray<{ date: string; publisher?: string; publishedAt?: string }>,
144
+ seriesId?: string,
145
+ ): FredProvenance | null {
142
146
  const ahead = observations.filter((observation) => observation.publisher?.trim());
143
147
  if (ahead.length === 0) return null;
144
148
  const publishers = [...new Set(ahead.map((observation) => observation.publisher!.trim()))];
@@ -149,7 +153,8 @@ export function fredProvenance(observations: ReadonlyArray<{ date: string; publi
149
153
  const publishedAt = times.at(-1);
150
154
  const publisher = publishers.join(" and ");
151
155
  return {
152
- source: `${publisher} via FRED${publishedAt ? `, published ${publishedAt.slice(0, 10)}` : ""}`,
156
+ // A series FRED does not carry names whose publication it is read from instead (TurkStat via the CBRT).
157
+ source: `${publisher} via ${(seriesId ? seriesNotOnFred(seriesId)?.via : null) ?? "FRED"}${publishedAt ? `, published ${publishedAt.slice(0, 10)}` : ""}`,
153
158
  publisher,
154
159
  ...(publishedAt ? { publishedAt } : {}),
155
160
  };
@@ -275,6 +280,11 @@ export interface FredListRow {
275
280
  group: string;
276
281
  }
277
282
 
283
+ /** Lower case without accents, so `turk` and `turkiye` find Türkiye. */
284
+ function foldAccents(text: string): string {
285
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
286
+ }
287
+
278
288
  /** How well a word of the filter matches a series, best first: its id or group name, a word of its group or title, the start of its id, then any text. */
279
289
  function matchTier(term: string, row: FredListRow, words: readonly string[]): number {
280
290
  if (row.id.toLowerCase() === term || row.group.toLowerCase() === term) return 0;
@@ -290,11 +300,11 @@ function matchTier(term: string, row: FredListRow, words: readonly string[]): nu
290
300
  * Catalog order breaks ties; an empty filter keeps the catalog as it is.
291
301
  */
292
302
  export function filterFredSeries(rows: readonly FredListRow[], filter: string): FredListRow[] {
293
- const terms = filter.toLowerCase().split(/\s+/).filter(Boolean);
303
+ const terms = foldAccents(filter).split(/\s+/).filter(Boolean);
294
304
  const ranked = rows.flatMap((row, index) => {
295
- const text = `${row.id} ${row.title} ${row.group}`.toLowerCase();
305
+ const text = foldAccents(`${row.id} ${row.title} ${row.group}`);
296
306
  if (!terms.every((term) => text.includes(term))) return [];
297
- const words = `${row.title} ${row.group}`.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
307
+ const words = foldAccents(`${row.title} ${row.group}`).split(/[^a-z0-9]+/).filter(Boolean);
298
308
  const tiers = terms.map((term) => matchTier(term, row, words));
299
309
  return [{ row, index, worst: Math.max(0, ...tiers), total: tiers.reduce((sum, tier) => sum + tier, 0) }];
300
310
  });
@@ -297,8 +297,9 @@ async function readSymbols(symbols: readonly string[], dataProvider: Parameters<
297
297
 
298
298
  /**
299
299
  * The symbols of `--file <path>` or standard input (`-`), with any typed
300
- * beside them, read by the parser `watchlist add` uses. Standard input is read
301
- * only when asked for, so a script that pipes in nothing never waits on it.
300
+ * beside them, read by the parser `watchlist add` uses, and its notes on what
301
+ * it left out. Standard input is read only when asked for, so a script that
302
+ * pipes in nothing never waits on it.
302
303
  */
303
304
  async function readListedSymbols(
304
305
  args: readonly string[],
@@ -306,13 +307,13 @@ async function readListedSymbols(
306
307
  market: MarketContext,
307
308
  ctx: Parameters<CliCommandDef["execute"]>[1],
308
309
  usage: string,
309
- ): Promise<string[]> {
310
+ ): Promise<{ symbols: string[]; notes: string[] }> {
310
311
  try {
311
312
  const list = await readSymbolList({
312
313
  args, file, stdinPiped: false, readEntries: (entries) => readNameEntries(entries, market.dataProvider),
313
314
  });
314
315
  if (list.symbols.length === 0) ctx.fail(`No symbols in ${file && file !== "-" ? file : "standard input"}.`, usage);
315
- return uniqueEntries(list.symbols);
316
+ return { symbols: uniqueEntries(list.symbols), notes: list.notes };
316
317
  } catch (error) {
317
318
  if (error instanceof SymbolListError) ctx.fail(error.message, usage);
318
319
  throw error;
@@ -718,9 +719,11 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
718
719
  await withMarketData(ctx, async (market) => {
719
720
  const read = phrase ? await readPhraseArgument(phrase, market.dataProvider) : null;
720
721
  if (read?.kind === "phrase") return answerPhrase(read.phrase, market, ctx);
722
+ // A list read from a file names what it left out: a stray separator, the cells beside a table's symbols.
723
+ const list = listed && !read ? await readListedSymbols(args, file, market, ctx, usage) : null;
721
724
  const symbols = read?.kind === "name" ? [read.name]
722
725
  : read?.kind === "symbols" ? uniqueEntries(read.symbols)
723
- : listed ? await readListedSymbols(args, file, market, ctx, usage)
726
+ : list ? list.symbols
724
727
  : await readSymbols(entries, market.dataProvider);
725
728
  // With several symbols, --exchange is for the ones that name no exchange of their own.
726
729
  const listings = await Promise.all(symbols.map((symbol) => requireCliListing(
@@ -791,6 +794,7 @@ async function runQuote(rawArgs: string[], ctx: Parameters<CliCommandDef["execut
791
794
  ...formerIssuers[index] ? { formerIssuer: formerIssuers[index] } : {},
792
795
  }));
793
796
  const notes = [
797
+ ...list?.notes ?? [],
794
798
  // FB now lists another security; the company that was FB trades as META. A symbol a
795
799
  // company renamed into (META) is what was typed: its change is in the row, not here.
796
800
  // GOGL was Golden Ocean, which merged into CMB.Tech; a fund lists there now.
@@ -22,6 +22,7 @@ import {
22
22
  } from "../../plugins/builtin/market-movers/model";
23
23
  import { MOVER_CANDIDATES } from "../../plugins/builtin/market-movers/client";
24
24
  import { fetchEconCalendarFrom } from "../../plugins/builtin/econ/calendar-source";
25
+ import { seriesNotOnFred } from "../../sources/gloomberb-cloud/fred-series";
25
26
  import { EXTENDED_SESSION_LABELS, getQuoteSessionFields, type ExtendedSession } from "../../market-data/market/status";
26
27
  import {
27
28
  COUNTRY_CYCLE,
@@ -311,9 +312,27 @@ function parseFilter<T extends string>(value: string | undefined, values: readon
311
312
  return match;
312
313
  }
313
314
 
315
+ /** One of the pane's regions, or any country by its two-letter code (TR, DE), upper-cased. */
316
+ function parseCountry(value: string | undefined, ctx: Parameters<CliCommandDef["execute"]>[1]): CountryFilter {
317
+ if (value == null) return COUNTRY_CYCLE[0]!;
318
+ const text = value.trim();
319
+ const region = COUNTRY_CYCLE.find((candidate) => candidate.toLowerCase() === text.toLowerCase());
320
+ if (region) return region;
321
+ if (/^[a-z]{2}$/i.test(text)) return text.toUpperCase();
322
+ return ctx.fail(`Unknown country "${value}".`, `Use ${COUNTRY_CYCLE.join(", ")}, or a two-letter country code such as TR.`);
323
+ }
324
+
325
+ /** A country the calendar lists nothing for says which ones it does list, so a typo (UK for GB) is not read as a quiet week. */
326
+ function unlistedCountryMessage(country: CountryFilter, events: ReadonlyArray<{ country: string }>): string | null {
327
+ if ((COUNTRY_CYCLE as readonly string[]).includes(country)) return null;
328
+ const listed = [...new Set(events.map((event) => event.country).filter((code) => /^[A-Z]{2}$/.test(code)))].sort();
329
+ if (listed.length === 0 || listed.includes(country)) return null;
330
+ return `The calendar lists no ${country} events; it lists ${listed.join(", ")}.`;
331
+ }
332
+
314
333
  async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
315
334
  const rawArgs = [...args];
316
- const country: CountryFilter = parseFilter(takeOption(rawArgs, "--country"), COUNTRY_CYCLE, "country", ctx);
335
+ const country = parseCountry(takeOption(rawArgs, "--country"), ctx);
317
336
  const impact: ImpactFilter = parseFilter(takeOption(rawArgs, "--impact"), FILTER_CYCLE, "impact", ctx);
318
337
  const fromOption = parseDateOption(takeOption(rawArgs, "--from"), "--from", ctx);
319
338
  rejectArgs(rawArgs, ECON_USAGE, ctx, "Filter with --country and --impact.");
@@ -363,7 +382,7 @@ async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>
363
382
  }, {
364
383
  dateKey: "date",
365
384
  defaultLimit: 50,
366
- empty: econEmptyMessage(from, listedThrough, listedAfter != null),
385
+ empty: unlistedCountryMessage(country, events) ?? econEmptyMessage(from, listedThrough, listedAfter != null),
367
386
  ...(footnote ? { footnote } : {}),
368
387
  columns: [
369
388
  // Text shows both halves of the event timestamp in UTC, never the host's zone; exports keep the source values.
@@ -485,7 +504,7 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
485
504
  const periodicity = periodicityOf(data.info?.frequency);
486
505
  const newestDate = rows.reduce<string | null>((newest, row) => (!newest || row.date > newest ? row.date : newest), null);
487
506
  // A newest value FRED does not list yet names its publisher, Treasury for a yield; FRED's own stay "FRED".
488
- const ahead = fredProvenance(data.observations.filter((observation) => observation.date === newestDate));
507
+ const ahead = fredProvenance(data.observations.filter((observation) => observation.date === newestDate), seriesId);
489
508
  // FRED names the series' publisher when Gloom Cloud passes it on, and when it last updated the series.
490
509
  const publisher = ahead?.publisher ?? data.info?.source?.trim();
491
510
  const publishedAt = ahead ? ahead.publishedAt : fredPublicationTime(data.info, newestDate);
@@ -498,7 +517,7 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
498
517
  ...(latest ? { latestObservation: exportRow(latest) } : {}),
499
518
  },
500
519
  freshness: rowsFreshness(rows, {
501
- source: ahead?.source ?? "FRED", status: "not-a-feed", basis: "published statistics", observedKey: "date", oldest: null,
520
+ source: ahead?.source ?? notOnFredSource(seriesId) ?? "FRED", status: "not-a-feed", basis: "published statistics", observedKey: "date", oldest: null,
502
521
  // A monthly or quarterly observation is dated the first of its period, not a day it was seen.
503
522
  ...(periodicity ? { periodicity } : {}),
504
523
  ...(publisher ? { publisher } : {}),
@@ -522,6 +541,12 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
522
541
  });
523
542
  }
524
543
 
544
+ /** Who a series FRED does not carry comes from, for a source line whose rows do not name it: `TurkStat via the CBRT`. */
545
+ function notOnFredSource(seriesId: string): string | null {
546
+ const elsewhere = seriesNotOnFred(seriesId);
547
+ return elsewhere ? `${elsewhere.publisher} via ${elsewhere.via}` : null;
548
+ }
549
+
525
550
  const YIELD_TENORS: Record<string, string> = { DGS3MO: "3M", DGS2: "2Y", DGS10: "10Y", DGS30: "30Y" };
526
551
 
527
552
  async function runYieldCurve(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
@@ -672,11 +697,11 @@ export const overviewCliCommands: CliCommandDef[] = [
672
697
  group: CLI_COMMAND_GROUPS.markets,
673
698
  usage: [ECON_USAGE],
674
699
  options: [
675
- { flags: "--country <region>", description: "US, G7, EU, or all (default all)" },
700
+ { flags: "--country <region>", description: "US, G7, EU, all (default), or one country by its two-letter code, such as TR" },
676
701
  { flags: "--impact <level>", description: "high, medium, low, or all (default all)" },
677
702
  { flags: "--from <date>", description: `First day listed, as ${DATE_OPTION_EXAMPLES} (default today, UTC); an earlier day lists past events from the stored calendar and says where it starts` },
678
703
  ],
679
- examples: ["econ", "econ --country US --impact high", "econ --from 2026-10-12 --country US", "econ --tail 20"],
704
+ examples: ["econ", "econ --country US --impact high", "econ --country TR", "econ --from 2026-10-12 --country US", "econ --tail 20"],
680
705
  },
681
706
  execute: runEcon,
682
707
  },
@@ -44,7 +44,7 @@ import type { SecFilingItem } from "../../types/data-provider";
44
44
  import type { NewsArticle } from "../../news/types";
45
45
  import type { TickerRecord } from "../../types/ticker";
46
46
  import type { CliCommandContext } from "../../types/plugin";
47
- import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolioMarketValue, resolvePortfolioPositionPnl } from "../../portfolio/position-metrics";
47
+ import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, lotCostFxRate, resolvePortfolioMarketValue, resolvePortfolioPositionPnl } from "../../portfolio/position-metrics";
48
48
  import { isFuturesTicker } from "../../portfolio/futures-positions";
49
49
  import { createBaseConverter } from "../base-converter";
50
50
  import { initMarketData, withMarketData } from "../context";
@@ -318,10 +318,13 @@ async function appendTickerPositions(lines: string[], tickerFile: TickerRecord |
318
318
  const positionCurrency = metrics.positionCurrency;
319
319
  const currentPrice = getPortfolioQuoteDisplay(metrics, quote)?.price ?? null;
320
320
  const currentPriceBase = currentPrice != null && quoteCurrency ? await toBase(currentPrice, quoteCurrency) : null;
321
- const costBasisBase = positionCurrency ? await toBase(metrics.signedCost, positionCurrency) : Number.NaN;
322
321
  const positionRate = positionCurrency ? await toBase(1, positionCurrency) : Number.NaN;
323
322
  const baseMetrics = getPortfolioPositionMetrics({ ...tickerFile, metadata: { ...tickerFile.metadata, positions: [position] } }, undefined, quoteCurrency,
324
323
  { currency: config.baseCurrency, convert: value => value * positionRate }, quote);
324
+ // A cost bought at an entered rate into the base currency converts at it, as its P&L does; any other at today's.
325
+ const costBasisBase = !positionCurrency ? Number.NaN
326
+ : lotCostFxRate(position, positionCurrency, config.baseCurrency) !== null ? baseMetrics.signedCost
327
+ : await toBase(metrics.signedCost, positionCurrency);
325
328
  const marketValueBase = resolvePortfolioMarketValue(baseMetrics, currentPriceBase)?.net ?? Number.NaN;
326
329
  const selectedPnl = resolvePortfolioPositionPnl(baseMetrics, currentPriceBase);
327
330
  const pnl = selectedPnl.value;
@@ -445,8 +445,8 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
445
445
  summary: "Named executive officers and what they were paid, read from the proxy statement and checked against the filing.",
446
446
  usage: ["EXEC NVDA"],
447
447
  keys: [key("y", "ear"), key("o", "pen filing")],
448
- data: pro(AS_FILED.pro),
449
- access: "pro",
448
+ data: { free: "As filed; each executive's total pay from the latest proxy", pro: "As filed; the pay breakdown and every year" },
449
+ access: "preview",
450
450
  bloomberg: ["MGMT"],
451
451
  },
452
452
  EK: {
@@ -681,10 +681,10 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
681
681
 
682
682
  // Watch markets
683
683
  MOST: {
684
- summary: "Today's top gainers, losers, most active and trending tickers with price, change, volume against average and market cap. On Pro, pre-market and after-hours movers and the gaps at the open.",
684
+ summary: "Today's top gainers, losers, most active and trending tickers with price, change, volume against average and market cap, and the pre-market and after-hours movers. On Pro, the gaps at the open.",
685
685
  usage: ["MOST"],
686
686
  keys: [TABS, OPEN],
687
- data: { free: DELAYED, pro: "Real-time, pre-market and after-hours" },
687
+ data: { free: DELAYED, pro: REAL_TIME },
688
688
  bloomberg: ["MOST"],
689
689
  },
690
690
  WEI: {
@@ -74,28 +74,59 @@ function looksLikeSymbol(cell: string): boolean {
74
74
  return SYMBOL_TOKEN.test(cell) && !(NUMBER_CELL.test(cell) && /[.,%]/.test(cell));
75
75
  }
76
76
 
77
+ /** How many cells a table's ignored-cells note names before it counts the rest. */
78
+ const IGNORED_PREVIEW = 8;
79
+
80
+ /** What a file or piped text holds: its symbols in order, and the cells of a table read by one column that were left out. */
81
+ interface SymbolText {
82
+ symbols: string[];
83
+ /** Cells beside the symbol column of a table with no header, in reading order; empty for a list or a CSV with a header. */
84
+ ignored: string[];
85
+ }
86
+
77
87
  /**
78
88
  * The symbols in a file or piped text, in order. A spreadsheet export reads
79
- * from the column its header names (Symbol, Ticker, Code), or else its first
80
- * column; a list split by commas, spaces or newlines gives every entry. A byte
81
- * order mark, Windows line ends, blank lines and lines starting with # or //
82
- * are ignored.
89
+ * from the column its header names (Symbol, Ticker, Code). Without a header,
90
+ * every cell is a symbol unless some column after the first holds a name, a
91
+ * price or a weight: that table is read by its first column, and the cells
92
+ * beside it are returned as `ignored` so the command can name them. A list
93
+ * split by commas, spaces or newlines gives every entry, even when a line of
94
+ * it is ragged or carries a stray separator (`"BIMAS ,"`). A byte order mark,
95
+ * Windows line ends, blank lines and lines starting with # or // are ignored.
83
96
  */
84
- export function parseSymbolText(text: string): string[] {
97
+ export function parseSymbolText(text: string): SymbolText {
85
98
  const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
86
99
  const lines = normalized.split("\n");
87
100
  const delimiter = detectDelimiter(lines);
88
101
  const records = (delimiter ? parseDelimited(normalized, delimiter) : lines.map((line) => line.trim().split(/\s+/)))
89
102
  .filter((cells) => cells.some(Boolean) && !isComment(cells[0] ?? ""));
90
- if (records.length === 0) return [];
103
+ if (records.length === 0) return { symbols: [], ignored: [] };
91
104
  const header = records[0]!.map(headerKey);
92
105
  const exact = header.findIndex((cell) => SYMBOL_HEADERS.has(cell));
93
106
  const column = exact >= 0 ? exact : header.findIndex((cell) => cell.includes("symbol") || cell.includes("ticker"));
94
- if (column >= 0) return records.slice(1).map((cells) => cells[column] ?? "").filter(Boolean);
95
- // Without a header, symbols laid out across columns are all symbols; a
96
- // table with names, prices or weights beside them is read by its first column.
97
- const grid = records.every((cells) => cells.every((cell, index) => !cell || (looksLikeSymbol(cell) && (index === 0 || !NUMBER_CELL.test(cell)))));
98
- return grid ? records.flat().filter(Boolean) : records.map((cells) => cells[0] ?? "").filter(Boolean);
107
+ if (column >= 0) return { symbols: records.slice(1).map((cells) => cells[column] ?? "").filter(Boolean), ignored: [] };
108
+ // A separator or quote mark stuck to a cell is dropped (and named) when it is read, so it does not make the list a table.
109
+ const cleaned = records.map((cells) => cells.map((cell) => stripStrayEdges(cell).text));
110
+ const grid = cleaned.every((cells) => cells.every((cell, index) => !cell || (looksLikeSymbol(cell) && (index === 0 || !NUMBER_CELL.test(cell)))));
111
+ if (grid) return { symbols: records.flat().filter(Boolean), ignored: [] };
112
+ return {
113
+ symbols: records.map((cells) => cells[0] ?? "").filter(Boolean),
114
+ ignored: records.flatMap((cells) => cells.slice(1)).filter(Boolean),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The note a table read by its first column gets, naming what was left out:
120
+ * `Read the first column as symbols; ignored the 4 cells beside it: Apple
121
+ * Inc., 336.64, Microsoft, 535.07.` Long tables name the first few and count
122
+ * the rest. Null when nothing was ignored.
123
+ */
124
+ function ignoredCellsNote(ignored: readonly string[]): string | null {
125
+ if (ignored.length === 0) return null;
126
+ const named = ignored.slice(0, IGNORED_PREVIEW).join(", ");
127
+ const more = ignored.length - IGNORED_PREVIEW;
128
+ const cells = ignored.length === 1 ? "the cell" : `the ${ignored.length} cells`;
129
+ return `Read the first column as symbols; ignored ${cells} beside it: ${named}${more > 0 ? ` and ${more} more` : ""}.`;
99
130
  }
100
131
 
101
132
  /** Whether standard input is a pipe or a file rather than a terminal, so a command may read its list from it. */
@@ -138,9 +169,10 @@ export class SymbolListError extends Error {
138
169
  * standard input when an argument is `-` or nothing else was given and input
139
170
  * is piped in. Repeats are dropped. A separator stuck to a symbol in a file, or
140
171
  * left on the end of what was typed (`UPST;`), is dropped with the quote marks
141
- * around it, and each is named in `strays` so the command can say so.
172
+ * around it, and the cells beside the symbols of a table read by its first
173
+ * column are left out; `notes` names each so the command can say so.
142
174
  */
143
- export async function readSymbolList(source: SymbolListSource): Promise<{ symbols: string[]; repeated: number; strays: string[] }> {
175
+ export async function readSymbolList(source: SymbolListSource): Promise<{ symbols: string[]; repeated: number; notes: string[] }> {
144
176
  const readStdin = source.readStdin ?? (() => Bun.stdin.text());
145
177
  const wantsStdin = source.args.includes("-") || source.file === "-";
146
178
  const typedArgs = source.args.filter((arg) => arg !== "-");
@@ -148,12 +180,14 @@ export async function readSymbolList(source: SymbolListSource): Promise<{ symbol
148
180
  const collected = [...typed];
149
181
  // A separator left on the end of what was typed: `UPST;`, or a list that ends in a comma.
150
182
  const strays = new Set<string>(stripStrayEdges(typedArgs.join(" ")).notes);
151
- const addCells = (cells: readonly string[]) => {
152
- for (const cell of cells) {
183
+ const ignored: string[] = [];
184
+ const addCells = ({ symbols, ignored: beside }: SymbolText) => {
185
+ for (const cell of symbols) {
153
186
  const cleaned = stripStrayEdges(cell);
154
187
  cleaned.notes.forEach((note) => strays.add(note));
155
188
  collected.push(cleaned.text);
156
189
  }
190
+ ignored.push(...beside);
157
191
  };
158
192
  if (source.file != null && source.file !== "-") {
159
193
  if (!source.file.trim() || source.file.startsWith("--")) throw new SymbolListError("--file needs a path, such as --file symbols.csv.");
@@ -169,5 +203,6 @@ export async function readSymbolList(source: SymbolListSource): Promise<{ symbol
169
203
  if (wantsStdin || (typed.length === 0 && source.file == null && (source.stdinPiped ?? stdinIsPiped()))) {
170
204
  addCells(parseSymbolText(await readStdin()));
171
205
  }
172
- return { ...uniqueSymbols(collected), strays: [...strays] };
206
+ const ignoredNote = ignoredCellsNote(ignored);
207
+ return { ...uniqueSymbols(collected), notes: [...strays, ...ignoredNote ? [ignoredNote] : []] };
173
208
  }
@@ -186,7 +186,13 @@ function ThemedCompositeChart({
186
186
  (drawing) => drawing.id === selectedDrawingId ? { ...drawing, color } : drawing,
187
187
  ));
188
188
  }, [selectedDrawingId]);
189
- const resolvedCursorDate = cursorDate === undefined ? internalCursorDate : cursorDate;
189
+ // A cursor the owner rests on without following its moves (a seasonal
190
+ // chart's latest week) is where the chart's own cursor goes back to: the
191
+ // pointer moves it as on any chart, and the pointer leaving returns it.
192
+ const restingCursor = cursorDate !== undefined && !onCursorDateChange;
193
+ const ownsCursor = cursorDate === undefined || restingCursor;
194
+ const resolvedCursorDate = cursorDate === undefined ? internalCursorDate
195
+ : restingCursor ? internalCursorDate ?? cursorDate : cursorDate;
190
196
  const totalWidth = Math.max(1, Math.floor(width));
191
197
  const totalHeight = Math.max(1, Math.floor(height));
192
198
  const seriesIdentityRef = useRef<ResolvedSeries[]>([]);
@@ -417,18 +423,18 @@ function ThemedCompositeChart({
417
423
  const nextTimestamp = timestamp !== null && Number.isFinite(timestamp) ? timestamp : null;
418
424
  if (lastCursorTimestampRef.current === nextTimestamp) return;
419
425
  lastCursorTimestampRef.current = nextTimestamp;
420
- if (cursorDate === undefined) setInternalCursorDate(date);
426
+ if (ownsCursor) setInternalCursorDate(date);
421
427
  onCursorDateChange?.(date);
422
428
  publishCursor?.(date && baseScene && nextTimestamp !== null ? compositeCursorAsOf(baseScene, date) : null);
423
- }, [baseScene, cursorDate, onCursorDateChange, publishCursor]);
429
+ }, [baseScene, onCursorDateChange, ownsCursor, publishCursor]);
424
430
  // Another chart taking the cursor, or clearing it, takes it from this one,
425
431
  // so it does not come back here later. Read live: a move since the render
426
432
  // makes this chart's cursor the shared one again.
427
433
  useEffect(() => {
428
434
  void holdsSharedCursor;
429
- if (!link || cursorDate !== undefined || lastCursorTimestampRef.current === null || link.held()) return;
435
+ if (!link || !ownsCursor || lastCursorTimestampRef.current === null || link.held()) return;
430
436
  updateCursor(null);
431
- }, [cursorDate, holdsSharedCursor, link, updateCursor]);
437
+ }, [holdsSharedCursor, link, ownsCursor, updateCursor]);
432
438
  // Esc on a chart showing another chart's cursor clears it, as on that chart.
433
439
  // While the tab's cursor is pinned the arrows move the pin and Esc unpins it.
434
440
  const updateKeyboardCursor = useCallback((date: Date | null) => {
@@ -207,14 +207,12 @@ export function CompositePanelSurface({
207
207
  const plotSpanFactor = 1 + compositeRightOffsetRatio(scene.timeScale);
208
208
  const bitmapSize = useStaticChartBitmapSize(plotSize.width, plotSize.height);
209
209
  const bitmap = useCompositePanelBitmap({ panel, bitmapSize, colors, isDesktopWeb, revealed: plotSize.revealed === true });
210
- // The level line follows the pointer only. A keyboard or shared cursor knows
211
- // its column, and the series markers and axis readout already say the value.
212
- // A tool placed from the keyboard is the exception: its end is a level.
213
- // On the desktop and the web an ordinary hover reads each series on its own
214
- // axis and draws only the vertical line; the level line, and the pointer's
215
- // value on the axes, belong to an armed or placing tool.
216
- const readsPointerLevel = !isDesktopWeb || armedTool !== null || activeDrag !== null;
217
- const pointerCursorYRatio = scene.cursorXRatio === null || !readsPointerLevel ? null : heldCursorYRatio;
210
+ // The chart under the pointer draws both lines: the level line follows the
211
+ // pointer, and its value sits on the axes, beside each series' own tag on
212
+ // the desktop and the web. A keyboard or shared cursor knows its column
213
+ // only, so a chart following another chart's cursor draws the time line
214
+ // alone. A tool placed from the keyboard is the exception: its end is a level.
215
+ const pointerCursorYRatio = scene.cursorXRatio === null ? null : heldCursorYRatio;
218
216
  const crosshair = useMemo(
219
217
  () => resolvePanelCrosshair(panel, bitmap, scene, pointerCursorYRatio, colors.crosshair),
220
218
  [bitmap, colors.crosshair, panel, pointerCursorYRatio, scene],
@@ -281,7 +281,8 @@ export function PriceAxisLabels({
281
281
  transform: "translateY(-50%)",
282
282
  whiteSpace: "pre",
283
283
  pointerEvents: "none",
284
- zIndex: 1,
284
+ // The value under the pointer reads whole, over a series' tag at that height.
285
+ zIndex: 3,
285
286
  }}
286
287
  >
287
288
  <Box flexDirection="row" width={axisWidth} justifyContent={axisLabelJustify} overflow="hidden">
@@ -274,6 +274,10 @@ export interface CompositeChartProps {
274
274
  remoteKind?: string;
275
275
  /** Permit panning into an older unloaded window so the owner can backfill it. */
276
276
  allowHistoricalBackfill?: boolean;
277
+ /**
278
+ * The owner's cursor. With `onCursorDateChange` the owner moves it; without,
279
+ * the cursor rests there while no pointer is on the plot.
280
+ */
277
281
  cursorDate?: Date | null;
278
282
  /**
279
283
  * A crosshair shared with other charts. By default an interactive chart
@@ -27,6 +27,7 @@ import {
27
27
  validatePositionShares,
28
28
  } from "../../../portfolio/mutations";
29
29
  import { loadFuturesEntrySize } from "../../../portfolio/futures-terms";
30
+ import { wholeUnitQuantityProblem } from "../../../portfolio/position-quantity";
30
31
  import type { CommandBarFieldValue } from "./types";
31
32
  import type { WorkflowStringValues } from "./broker";
32
33
  import { coerceFieldString } from "../helpers";
@@ -248,6 +249,9 @@ export function createCommandBarCollectionWorkflowActions(options: {
248
249
  // Negative for a short; zero is no position. A future counts contracts, and the messages say so.
249
250
  const quantityLabel = positionQuantityLabel(ticker);
250
251
  const shares = readTypedNumber(coerceFieldString(values.shares), quantityLabel);
252
+ // 1.250 on a listing held in whole lots could be 1,250 as well, and asks the same way.
253
+ const lots = wholeUnitQuantityProblem(coerceFieldString(values.shares).trim(), ticker, { label: quantityLabel });
254
+ if (lots) throw new Error(lots);
251
255
  const sharesError = validatePositionShares(shares, quantityLabel);
252
256
  if (sharesError) {
253
257
  throw new Error(t(sharesError));
@@ -12,6 +12,7 @@ import {
12
12
  } from "../../portfolio/mutations";
13
13
  import { isFuturesTicker } from "../../portfolio/futures-positions";
14
14
  import { loadFuturesEntrySize } from "../../portfolio/futures-terms";
15
+ import { wholeUnitQuantityProblem } from "../../portfolio/position-quantity";
15
16
  import { useAppDispatch, useAppSelector, useAppStateRef } from "../../state/app/context";
16
17
  import { saveConfigImmediately } from "../../state/config-save-scheduler";
17
18
  import { resolveTickerSearch, upsertTickerFromSearchResult, type ResolvedTickerSearch } from "../../tickers/search";
@@ -291,6 +292,13 @@ export function useOnboardingPositions({
291
292
  if (shares === null) {
292
293
  nextTicker = addTickerToPortfolio(ticker, portfolioId).ticker;
293
294
  } else {
295
+ // 1.250 on a listing held in whole lots could be 1,250 as well, and asks as 1,250 does.
296
+ const lots = wholeUnitQuantityProblem(current.shares.trim(), ticker, { label: quantityLabel });
297
+ if (lots) {
298
+ setFieldIdx(1);
299
+ setError(lots);
300
+ return false;
301
+ }
294
302
  const costBasis = avgCost ?? result.quote?.price;
295
303
  if (costBasis === undefined || costBasis === null || !Number.isFinite(costBasis)) {
296
304
  setFieldIdx(2);