gloomberb 0.18.2 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +106 -5
  3. package/src/api-client/data.ts +15 -0
  4. package/src/api-client/index.ts +2 -0
  5. package/src/api-client/request.ts +5 -3
  6. package/src/api-client/telemetry.ts +17 -0
  7. package/src/app/global-shortcuts.ts +1 -16
  8. package/src/app/runtime/update.ts +28 -8
  9. package/src/architecture/pane-tabs-allowlist.json +65 -0
  10. package/src/cli/commands/market.ts +73 -33
  11. package/src/cli/commands/ticker.ts +82 -25
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +8 -0
  14. package/src/cli/issuer-membership.ts +103 -0
  15. package/src/cli/listing-arg.ts +15 -2
  16. package/src/cli/name-entries.ts +51 -12
  17. package/src/cli/pane-functions/capabilities.ts +2 -0
  18. package/src/cli/pane-functions/catalog.ts +16 -0
  19. package/src/cli/pane-functions/dom.ts +2 -1
  20. package/src/cli/pane-functions/headless.ts +25 -6
  21. package/src/cli/pane-functions/index.ts +64 -5
  22. package/src/cli/pane-functions/resolver.ts +19 -5
  23. package/src/cli/report-tables.ts +136 -18
  24. package/src/cli/result.ts +12 -4
  25. package/src/cli/symbol-list.ts +47 -11
  26. package/src/components/chart/composite/composite-chart.tsx +14 -5
  27. package/src/components/chart/composite/drawing-store.ts +26 -6
  28. package/src/components/chart/composite/interactions.ts +2 -1
  29. package/src/components/chart/composite/panel-surface.tsx +11 -0
  30. package/src/components/chart/composite/types.ts +6 -0
  31. package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
  32. package/src/components/command-bar/routes/root/command-items.ts +32 -6
  33. package/src/components/data-table/view.tsx +23 -3
  34. package/src/components/index.ts +1 -0
  35. package/src/components/layout/detached-pane-shell.tsx +1 -0
  36. package/src/components/layout/floating-pane.tsx +1 -0
  37. package/src/components/layout/header.tsx +228 -55
  38. package/src/components/layout/pane/header.tsx +10 -3
  39. package/src/components/layout/pane/index.tsx +1 -0
  40. package/src/components/layout/pane/pane-tabs.tsx +9 -1
  41. package/src/components/ui/stat-grid.tsx +28 -3
  42. package/src/components/ui/status.tsx +3 -1
  43. package/src/core/app-services.ts +4 -0
  44. package/src/i18n/es.ts +22 -0
  45. package/src/i18n/ja.ts +22 -0
  46. package/src/i18n/ko.ts +22 -0
  47. package/src/i18n/zh-cn.ts +22 -0
  48. package/src/i18n/zh-tw.ts +22 -0
  49. package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
  50. package/src/plugins/builtin/alerts/events.ts +1 -0
  51. package/src/plugins/builtin/alerts/research-rules.ts +1 -0
  52. package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
  53. package/src/plugins/builtin/attention/pane.tsx +16 -14
  54. package/src/plugins/builtin/awards/pane.tsx +6 -6
  55. package/src/plugins/builtin/backtest/export.ts +1 -1
  56. package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
  57. package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
  58. package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
  59. package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
  60. package/src/plugins/builtin/catalysts/client.ts +2 -2
  61. package/src/plugins/builtin/catalysts/pane.tsx +3 -3
  62. package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
  63. package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
  64. package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
  65. package/src/plugins/builtin/company-attention/pane.tsx +13 -11
  66. package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
  67. package/src/plugins/builtin/correlation/headless.ts +2 -1
  68. package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
  69. package/src/plugins/builtin/correlation/settings.ts +1 -0
  70. package/src/plugins/builtin/cot/pane.tsx +4 -3
  71. package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
  72. package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
  73. package/src/plugins/builtin/doe/pane.tsx +1 -1
  74. package/src/plugins/builtin/equity-screener/headless.ts +10 -0
  75. package/src/plugins/builtin/equity-screener/model.ts +28 -0
  76. package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
  77. package/src/plugins/builtin/exposure/pane.tsx +10 -9
  78. package/src/plugins/builtin/gpu/board.tsx +4 -1
  79. package/src/plugins/builtin/gpu/changes.tsx +10 -10
  80. package/src/plugins/builtin/gpu/equities.tsx +4 -1
  81. package/src/plugins/builtin/gpu/history.tsx +4 -2
  82. package/src/plugins/builtin/gpu/pane.tsx +5 -3
  83. package/src/plugins/builtin/holders/client.ts +2 -1
  84. package/src/plugins/builtin/holders/headless.ts +2 -1
  85. package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
  86. package/src/plugins/builtin/insider/base-values.ts +11 -7
  87. package/src/plugins/builtin/insider/client.ts +3 -0
  88. package/src/plugins/builtin/insider/headless.ts +19 -6
  89. package/src/plugins/builtin/insider/index.tsx +17 -8
  90. package/src/plugins/builtin/insider/issuer.ts +34 -4
  91. package/src/plugins/builtin/insider/signals-client.ts +2 -2
  92. package/src/plugins/builtin/insider/table-model.ts +35 -13
  93. package/src/plugins/builtin/insider/table.tsx +5 -2
  94. package/src/plugins/builtin/iv-history/events.ts +145 -0
  95. package/src/plugins/builtin/iv-history/format.ts +3 -0
  96. package/src/plugins/builtin/iv-history/headless.ts +91 -7
  97. package/src/plugins/builtin/jodi/pane.tsx +4 -4
  98. package/src/plugins/builtin/market-movers/client.ts +4 -3
  99. package/src/plugins/builtin/market-movers/screener.ts +4 -3
  100. package/src/plugins/builtin/notes/cli.ts +1 -1
  101. package/src/plugins/builtin/notes/export.ts +17 -6
  102. package/src/plugins/builtin/notes/files.ts +16 -5
  103. package/src/plugins/builtin/options/enrichment-model.ts +10 -9
  104. package/src/plugins/builtin/options/model-spot.ts +70 -4
  105. package/src/plugins/builtin/options/view.tsx +41 -13
  106. package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
  107. package/src/plugins/builtin/options-positioning/headless.ts +11 -3
  108. package/src/plugins/builtin/options-positioning/model.ts +24 -0
  109. package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
  110. package/src/plugins/builtin/options-scenario/client.ts +19 -1
  111. package/src/plugins/builtin/options-scenario/headless.ts +13 -6
  112. package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
  113. package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
  114. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  115. package/src/plugins/builtin/power/client.ts +2 -2
  116. package/src/plugins/builtin/power/pane.tsx +36 -31
  117. package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
  118. package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
  119. package/src/plugins/builtin/seasonality/client.ts +2 -1
  120. package/src/plugins/builtin/sectors/client.ts +4 -3
  121. package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
  122. package/src/plugins/builtin/shared/research-trading.tsx +2 -0
  123. package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
  124. package/src/plugins/builtin/shared/volatility/options.ts +2 -1
  125. package/src/plugins/builtin/social-mentions/client.ts +2 -1
  126. package/src/plugins/builtin/vol-surface/client.ts +23 -3
  127. package/src/plugins/builtin/vol-surface/headless.ts +33 -14
  128. package/src/plugins/builtin/vol-surface/model.ts +30 -10
  129. package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
  130. package/src/plugins/builtin/vol-surface/stored.ts +34 -3
  131. package/src/plugins/builtin/vol-surface/tables.ts +14 -0
  132. package/src/renderers/dom/desktop/controls.tsx +1 -1
  133. package/src/renderers/dom/desktop/query-bar.tsx +2 -0
  134. package/src/renderers/dom/host/tabs.tsx +16 -5
  135. package/src/renderers/dom/host/top-surface.ts +127 -0
  136. package/src/renderers/dom/styles.css +39 -0
  137. package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
  138. package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
  139. package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
  140. package/src/renderers/electrobun/view/news-source.ts +4 -2
  141. package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
  142. package/src/renderers/opentui/start.tsx +7 -0
  143. package/src/sources/gloomberb-cloud/index.ts +7 -0
  144. package/src/sources/provider-router/index.ts +16 -0
  145. package/src/telemetry/crash-reports.ts +4 -0
  146. package/src/telemetry/update-failures-node.ts +59 -0
  147. package/src/telemetry/update-failures.ts +307 -0
  148. package/src/tickers/company-name-listing.ts +9 -7
  149. package/src/tickers/company-name.ts +43 -0
  150. package/src/tickers/listing-fate.ts +30 -0
  151. package/src/types/data-provider.ts +6 -0
  152. package/src/types/headless.ts +7 -0
  153. package/src/types/issuer-listings.ts +28 -0
  154. package/src/updater/failure.ts +163 -0
  155. package/src/updater/index.ts +87 -23
  156. package/src/utils/currency-pair.ts +26 -0
  157. package/src/utils/sec.ts +68 -0
@@ -5,6 +5,7 @@ import type {
5
5
  } from "../../../types/plugin";
6
6
  import { loadHolderSnapshot } from "./client";
7
7
  import { holderValueCurrency } from "./format";
8
+ import { logFailure } from "../../../utils/log-failure";
8
9
  import type { HolderData } from "../../../types/financials";
9
10
  import { SEC_FILINGS } from "../shared/report-freshness";
10
11
  import type { BeneficialOwnersPayload } from "../../../api-client/beneficial-owners";
@@ -157,7 +158,7 @@ export function createHoldersHeadless(
157
158
  const [payload, snapshot] = await Promise.all([
158
159
  dependencies.loadBeneficialOwners(symbol, { form: beneficialRouteForm(form), history }, ctx),
159
160
  // The 13F column is a join; the 13D/13G rows stand without it.
160
- dependencies.loadSnapshot(symbol, args, ctx).catch(() => null),
161
+ dependencies.loadSnapshot(symbol, args, ctx).catch(logFailure("holders.headless", "Load holder snapshot for filing comparison failed", null)),
161
162
  ]);
162
163
  const sharesOutstanding = snapshot?.sharesOutstanding ?? null;
163
164
  const rows = buildBeneficialReportRows(payload, {
@@ -2,6 +2,7 @@ import { listThirteenFForms, normalizeCik, searchThirteenFFunds } from "../thirt
2
2
  import { isoDateToday, isoDateYearsAgo } from "../../../utils/calendar-date";
3
3
  import { buildPeriodReports } from "../thirteenf/model";
4
4
  import type { HolderRow } from "./types";
5
+ import { logFailure } from "../../../utils/log-failure";
5
6
 
6
7
  export interface Holder13FMatch {
7
8
  cik: string;
@@ -100,8 +101,9 @@ export async function loadHolder13FMatches(
100
101
  filedAsOfDate: form?.filedAsOfDate,
101
102
  tableValueTotal: report?.complete && forms.length < 100 ? report.tableValueTotal : null,
102
103
  });
103
- } catch {
104
+ } catch (error) {
104
105
  // Matching 13F metadata is an enhancement; holder rows should remain usable.
106
+ if (!signal.aborted) logFailure("holders.thirteenf-match", "Match holder fund filings failed")(error);
105
107
  }
106
108
  }
107
109
  }
@@ -2,6 +2,7 @@ import type { HeadlessPaneColumn, HeadlessPaneRow } from "../../../types/headles
2
2
  import { fxCloseOnOrBefore, loadDailyFxCloses, type FxCloses, type FxHistoryClient } from "../../../market-data/fx-closes";
3
3
  import { logFailure } from "../../../utils/log-failure";
4
4
  import { formatCompact, formatNumber } from "../../../utils/format";
5
+ import { quotedFxPair } from "../../../utils/currency-pair";
5
6
 
6
7
  /** A Form 4 states its prices and values in US dollars. */
7
8
  export const FORM4_CURRENCY = "USD";
@@ -21,8 +22,9 @@ const dash = (value: unknown, format: (value: number) => string) => (typeof valu
21
22
  * states them). With another base currency (CAD), each value is also given
22
23
  * in it at the daily close of USD/CAD on its transaction day, or the last
23
24
  * close before a weekend or holiday (`fxCloseOnOrBefore` over the history
24
- * PORT and PEB read), with the rate and that close's date beside it. When the
25
- * closes cannot be read the values stay in USD and the note says why.
25
+ * PORT and PEB read), with the rate and that close's date beside it. The rate
26
+ * reads as markets quote the pair (USD/CAD, EUR/USD), named by `fxPair`. When
27
+ * the closes cannot be read the values stay in USD and the note says why.
26
28
  */
27
29
  export async function withInsiderBaseValues(
28
30
  rows: readonly HeadlessPaneRow[],
@@ -33,7 +35,8 @@ export async function withInsiderBaseValues(
33
35
  const labelled: HeadlessPaneRow[] = rows.map((row) => ({ ...row, currency: FORM4_CURRENCY }));
34
36
  const base = baseCurrency.trim().toUpperCase();
35
37
  if (!base || base === FORM4_CURRENCY) return { rows: labelled, columns: null, note: null };
36
- const pair = `${FORM4_CURRENCY}/${base}`;
38
+ // The pair as markets quote it: USD/CAD, but EUR/USD for a euro base.
39
+ const pair = quotedFxPair(FORM4_CURRENCY, base, 1).pair;
37
40
  const days = labelled.flatMap((row) => typeof row.transactionDate === "string" && typeof row.totalValue === "number"
38
41
  ? [row.transactionDate.slice(0, 10)] : []).sort();
39
42
  let closes: FxCloses | null = null;
@@ -47,13 +50,14 @@ export async function withInsiderBaseValues(
47
50
  const converted = labelled.map((row) => {
48
51
  const day = typeof row.transactionDate === "string" ? row.transactionDate.slice(0, 10) : null;
49
52
  const reading = closes && day && typeof row.totalValue === "number" ? fxCloseOnOrBefore(closes, day) : null;
50
- // The close is USD per unit of the base currency; the rate is the base currency per dollar.
51
- const fxRate = reading ? 1 / reading.rate : null;
53
+ // The close is USD per unit of the base currency: the base currency per dollar is its inverse.
54
+ const perDollar = reading ? 1 / reading.rate : null;
52
55
  return {
53
56
  ...row,
54
57
  baseCurrency: base,
55
- valueBase: fxRate != null ? (row.totalValue as number) * fxRate : null,
56
- fxRate,
58
+ valueBase: perDollar != null ? (row.totalValue as number) * perDollar : null,
59
+ fxPair: pair,
60
+ fxRate: perDollar != null ? quotedFxPair(FORM4_CURRENCY, base, perDollar).rate : null,
57
61
  fxDate: reading?.date ?? null,
58
62
  };
59
63
  });
@@ -7,6 +7,9 @@ import { filesAsForeignPrivateIssuer } from "./issuer";
7
7
  // this many filings or the company's history ends.
8
8
  const SEC_FILING_SCAN_LIMIT = 20_000;
9
9
 
10
+ /** The company's own filings `fn INS` reads by default, and `insider` sums up as it does. */
11
+ export const INSIDER_REPORT_FILINGS = 20;
12
+
10
13
  export interface InsiderFilingsLoad {
11
14
  /** The rows of the company's own Form 4 filings, newest first. */
12
15
  parsed: ParsedInsiderFiling[];
@@ -6,8 +6,8 @@ import type {
6
6
  HeadlessPaneRow,
7
7
  } from "../../../types/plugin";
8
8
  import { formatCompact, formatCurrency } from "../../../utils/format";
9
- import { loadParsedInsiderFilings, type InsiderFilingsLoad } from "./client";
10
- import { insiderEmptyNotice } from "./issuer";
9
+ import { INSIDER_REPORT_FILINGS, loadParsedInsiderFilings, type InsiderFilingsLoad } from "./client";
10
+ import { insiderEmptyNotice, loadHomeInsiderReportsNote } from "./issuer";
11
11
  import { FORM4_CURRENCY, withInsiderBaseValues } from "./base-values";
12
12
  import {
13
13
  buildInsiderRows,
@@ -21,7 +21,7 @@ import {
21
21
  import { isAmendedInsiderFiling, relevantInsiderAmendments } from "./amendments";
22
22
  import { formatInsiderName } from "./display";
23
23
  import { SEC_FILINGS } from "../shared/report-freshness";
24
- import { resolveHeadlessIssuerListing } from "../shared/headless-market-data";
24
+ import { resolveHeadlessInstrument, resolveHeadlessIssuerListing } from "../shared/headless-market-data";
25
25
  import { noSecReportsNotice } from "../../../utils/sec";
26
26
  import { parsePublicTickerKey } from "../../../utils/exchanges";
27
27
  import type { InsiderSignalRoleGroup, InsiderSignalSide, InsiderSignalsPayload } from "../../../api-client/insider-signals";
@@ -310,6 +310,20 @@ async function noSecReportsEmpty(ctx: HeadlessPaneContext, key: string): Promise
310
310
  return noSecReportsNotice({ symbol: parsePublicTickerKey(key).symbol, exchange: listing.exchange, name: listing.name ?? null });
311
311
  }
312
312
 
313
+ /**
314
+ * What INS says for a company with no Form 4 of its own: why there is none,
315
+ * and for a company from abroad, that its home market's insider reports are
316
+ * not covered either ("Canadian insider reports (SEDI) not covered.").
317
+ */
318
+ async function insiderEmpty(ctx: HeadlessPaneContext, key: string, foreignPrivateIssuer: boolean, noFilings: boolean): Promise<string> {
319
+ const [noReports, { exchange }] = await Promise.all([
320
+ noFilings ? noSecReportsEmpty(ctx, key) : null,
321
+ resolveHeadlessInstrument(ctx, key),
322
+ ]);
323
+ const home = await loadHomeInsiderReportsNote(ctx.marketData, key, exchange, foreignPrivateIssuer);
324
+ return noReports ? [noReports, home].filter(Boolean).join(" ") : insiderEmptyNotice(foreignPrivateIssuer, home);
325
+ }
326
+
313
327
  export function createInsiderHeadless(
314
328
  dependencies: InsiderHeadlessDependencies = defaultDependencies,
315
329
  ): HeadlessPaneDefinition<"rows"> {
@@ -346,7 +360,7 @@ export function createInsiderHeadless(
346
360
  aliases: ["count", "rows"],
347
361
  description: "Maximum of the company's own Form 4 and Form 4/A filings to parse; without a ticker, companies to rank.",
348
362
  type: "integer",
349
- defaultValue: 20,
363
+ defaultValue: INSIDER_REPORT_FILINGS,
350
364
  minimum: 1,
351
365
  maximum: 100,
352
366
  },
@@ -510,8 +524,7 @@ export function createInsiderHeadless(
510
524
  }
511
525
  const limit = Number(args.options.limit);
512
526
  const { parsed, foreignPrivateIssuer, noFilings } = await dependencies.loadParsed(symbol, limit, args, ctx);
513
- const empty = parsed.length > 0 ? null
514
- : (noFilings ? await noSecReportsEmpty(ctx, symbol) : null) ?? insiderEmptyNotice(foreignPrivateIssuer);
527
+ const empty = parsed.length > 0 ? null : await insiderEmpty(ctx, symbol, foreignPrivateIssuer, noFilings);
515
528
  const name = String(args.options.name ?? "").trim().toLocaleLowerCase();
516
529
  const type = isInsiderTypeFilter(args.options.type) ? args.options.type : "all";
517
530
  const owned = name
@@ -30,7 +30,7 @@ import {
30
30
  type StatItem,
31
31
  } from "../../../components";
32
32
  import { useDebouncedPluginPaneState, usePluginPaneState } from "../../runtime";
33
- import { mayBeSecEquityListing, mayBeUsEquityTicker, noSecReportsNotice, nonUsSecListingVenue } from "../../../utils/sec";
33
+ import { homeInsiderReportsNote, mayBeSecEquityListing, mayBeUsEquityTicker, noSecReportsNotice, nonUsSecListingVenue } from "../../../utils/sec";
34
34
  import { parsePublicTickerKey } from "../../../utils/exchanges";
35
35
  import { formatCompact } from "../../../utils/format";
36
36
  import { nextHeaderSort } from "../../../utils/sort-values";
@@ -56,6 +56,7 @@ import {
56
56
  } from "./model";
57
57
  import { formatInsiderName } from "./display";
58
58
  import { insiderHeadless } from "./headless";
59
+ import { FORM4_CURRENCY } from "./base-values";
59
60
  import { isInsiderForm } from "./insider-data";
60
61
  import { filesAsForeignPrivateIssuer, insiderEmptyNotice, isOtherIssuerFiling } from "./issuer";
61
62
  import { relevantInsiderAmendments } from "./amendments";
@@ -116,9 +117,10 @@ const TYPE_FILTER_LABELS: Record<InsiderTypeFilter, string> = {
116
117
  * security when there are several. A side with no trades shows 0 shares,
117
118
  * muted. Buys read in the gain colour and sales in the loss colour, as in
118
119
  * the table; when the Type filter hides open-market trades the figures dim
119
- * and say so.
120
+ * and say so. With `currency` the dollars read with its code (1.2M USD), as
121
+ * the table's CCY column names them.
120
122
  */
121
- function summaryItems(figures: InsiderSummaryFigures, typeFilter: InsiderTypeFilter): StatItem[] {
123
+ function summaryItems(figures: InsiderSummaryFigures, typeFilter: InsiderTypeFilter, currency?: string): StatItem[] {
122
124
  const securities = new Set(figures.totals.map((total) => total.security));
123
125
  const hidden = typeFilter === "other";
124
126
  const items: StatItem[] = [];
@@ -132,7 +134,8 @@ function summaryItems(figures: InsiderSummaryFigures, typeFilter: InsiderTypeFil
132
134
  label,
133
135
  value: total.unreconciled ? "Unavailable" : `${formatCompact(total.shares)} shares`,
134
136
  detail: [
135
- total.unreconciled ? "amended" : total.knownValue ? `$${formatCompact(total.value)}` : "value unavailable",
137
+ total.unreconciled ? "amended" : !total.knownValue ? "value unavailable"
138
+ : currency ? `${formatCompact(total.value)} ${currency}` : `$${formatCompact(total.value)}`,
136
139
  securities.size > 1 ? total.security : null,
137
140
  hidden ? "not in this view" : null,
138
141
  ].filter(Boolean).join(" · "),
@@ -337,7 +340,10 @@ function InsiderTradesView({ ticker, viewBar, state, width, height, focused, sta
337
340
  const order = new Map(rows.map((row, index) => [row.id, index]));
338
341
  return sortStackItems(rows, sort, compareInsiderRows, (a, b) => order.get(a.id)! - order.get(b.id)!);
339
342
  }, [rows, sort]);
340
- const columns = useMemo(() => buildInsiderColumns(width, rows), [rows, width]);
343
+ // Form 4 amounts are dollars: a reader whose base currency is another one gets them named, as PF names a holding's.
344
+ const baseCurrency = useAppSelector((state) => state.config.baseCurrency);
345
+ const amountCurrency = baseCurrency.trim().toUpperCase() === FORM4_CURRENCY ? undefined : FORM4_CURRENCY;
346
+ const columns = useMemo(() => buildInsiderColumns(width, rows, amountCurrency), [amountCurrency, rows, width]);
341
347
 
342
348
  // A narrowed list that cannot scroll cannot ask for older filings, so they
343
349
  // load here until it fills a page, back to a year at most.
@@ -358,7 +364,7 @@ function InsiderTradesView({ ticker, viewBar, state, width, height, focused, sta
358
364
  const lastSummaryRef = useRef<{ scope: string; figures: InsiderSummaryFigures } | null>(null);
359
365
  if (summary) lastSummaryRef.current = { scope: summaryScope, figures: summary };
360
366
  const shownSummary = summary ?? (lastSummaryRef.current?.scope === summaryScope ? lastSummaryRef.current.figures : null);
361
- const statItems = useMemo(() => shownSummary ? summaryItems(shownSummary, typeFilter) : [], [shownSummary, typeFilter]);
367
+ const statItems = useMemo(() => shownSummary ? summaryItems(shownSummary, typeFilter, amountCurrency) : [], [amountCurrency, shownSummary, typeFilter]);
362
368
  // The totals miss window filings only when the window passes the cap.
363
369
  const windowPartial = shownCount < windowCount;
364
370
  const insiderOptions = useMemo(() => {
@@ -483,9 +489,12 @@ function InsiderTradesView({ ticker, viewBar, state, width, height, focused, sta
483
489
  if (loading && allFilings.length === 0) return underBar(<Spinner label="Loading insider filings..." />);
484
490
  if (error && allFilings.length === 0) return underBar(<EmptyState title="Insider filings unavailable." message={error} />);
485
491
  if (noOwnFilings) {
492
+ // A company from abroad also says its home market's insider reports are not covered (RY:TSX: SEDI).
493
+ const foreignPrivateIssuer = filesAsForeignPrivateIssuer(allFilings);
494
+ const home = homeInsiderReportsNote({ exchange: abroadVenue, foreignPrivateIssuer });
486
495
  return underBar(renderFilingNotice(abroadVenue && allFilings.length === 0
487
- ? noSecReportsNotice({ symbol: parsePublicTickerKey(ticker.metadata.ticker).symbol, exchange: abroadVenue, name: ticker.metadata.name ?? null })
488
- : insiderEmptyNotice(filesAsForeignPrivateIssuer(allFilings))));
496
+ ? [noSecReportsNotice({ symbol: parsePublicTickerKey(ticker.metadata.ticker).symbol, exchange: abroadVenue, name: ticker.metadata.name ?? null }), home].filter(Boolean).join(" ")
497
+ : insiderEmptyNotice(foreignPrivateIssuer, home)));
489
498
  }
490
499
 
491
500
  // Until a filing is read there is nothing to say; after that the empty
@@ -1,4 +1,7 @@
1
- import type { SecFilingItem } from "../../../types/data-provider";
1
+ import type { DataProvider, SecFilingItem } from "../../../types/data-provider";
2
+ import { parsePublicTickerKey } from "../../../utils/exchanges";
3
+ import { logFailure } from "../../../utils/log-failure";
4
+ import { homeInsiderReportsNote, venueHomeCountry } from "../../../utils/sec";
2
5
  import { parseForm4IssuerCik } from "./insider-data";
3
6
 
4
7
  /**
@@ -40,10 +43,37 @@ export function filesAsForeignPrivateIssuer(filings: readonly SecFilingItem[]):
40
43
 
41
44
  /**
42
45
  * What the pane, `fn INS` and `insider` say when a company has no Form 4 of
43
- * its own. Each already names the company above it.
46
+ * its own, and beside it, for a company from abroad, that its insiders'
47
+ * reports at home are not covered either (`homeInsiderReportsNote`). Each
48
+ * already names the company above it.
44
49
  */
45
- export function insiderEmptyNotice(foreignPrivateIssuer: boolean): string {
46
- return foreignPrivateIssuer
50
+ export function insiderEmptyNotice(foreignPrivateIssuer: boolean, home: string | null = null): string {
51
+ const notice = foreignPrivateIssuer
47
52
  ? "No Form 4 insiders: foreign private issuers file none."
48
53
  : "No Form 4 insider filings.";
54
+ return home ? `${notice} ${home}` : notice;
55
+ }
56
+
57
+ /**
58
+ * `homeInsiderReportsNote` for a listing with no Form 4 of its own, placed by
59
+ * its company's country where the source knows the company (RY in New York
60
+ * is Royal Bank of Canada), else by its venue abroad (RY:TSX). A lookup that
61
+ * fails leaves the venue to say. A US listing of a company filing as a
62
+ * domestic one has no home market to name, and costs no lookup.
63
+ */
64
+ export async function loadHomeInsiderReportsNote(
65
+ provider: Pick<DataProvider, "getIssuerListings">,
66
+ key: string,
67
+ exchange: string | undefined,
68
+ foreignPrivateIssuer: boolean,
69
+ ): Promise<string | null> {
70
+ const parsed = parsePublicTickerKey(key);
71
+ const venue = parsed.exchange ?? exchange ?? "";
72
+ if (!foreignPrivateIssuer && !venueHomeCountry(venue)) return null;
73
+ const { getIssuerListings } = provider;
74
+ const issuer = getIssuerListings
75
+ ? await Promise.resolve().then(() => getIssuerListings.call(provider, parsed.symbol, venue))
76
+ .catch(logFailure("plugins.insider.issuer", "Reading the company's listings failed", null))
77
+ : null;
78
+ return homeInsiderReportsNote({ country: issuer?.country, exchange: venue, foreignPrivateIssuer });
49
79
  }
@@ -109,7 +109,7 @@ export async function loadInsiderSignals(
109
109
  if (isAccessDenied(error)) throw new Error(CLOUD_SESSION_REQUIRED, { cause: error });
110
110
  throw unavailableOnServer(error, INSIDER_SIGNALS_NOT_AVAILABLE);
111
111
  }),
112
- { force, validate: validateInsiderSignals },
112
+ { force, validate: validateInsiderSignals, signal },
113
113
  );
114
114
  }
115
115
 
@@ -187,7 +187,7 @@ export async function loadInsiderCompany(
187
187
  if (isAccessDenied(error)) throw new Error(CLOUD_SESSION_REQUIRED, { cause: error });
188
188
  throw unavailableOnServer(error, INSIDERS_NOT_AVAILABLE);
189
189
  }),
190
- { force, validate: companyValidator(query.ticker) },
190
+ { force, validate: companyValidator(query.ticker), signal },
191
191
  )));
192
192
  return bothSides(buy!, sell!);
193
193
  }
@@ -1,5 +1,5 @@
1
1
  import type { DataTableColumn } from "../../../components";
2
- import { displayWidth, formatCompact, formatCurrency } from "../../../utils/format";
2
+ import { displayWidth, formatCompact, formatCurrency, formatNumber } from "../../../utils/format";
3
3
  import { isAmendedInsiderFiling } from "./amendments";
4
4
  import { insiderSecurityTag, insiderTypeLabel, insiderTypeTone, isOpenMarketTrade, type InsiderTypeTone } from "./display";
5
5
  import {
@@ -10,12 +10,18 @@ import {
10
10
  type ParsedInsiderFiling,
11
11
  } from "./model";
12
12
 
13
- export type InsiderColumnId = "date" | "insider" | "role" | "type" | "security" | "shares" | "price" | "value";
13
+ export type InsiderColumnId = "date" | "insider" | "role" | "type" | "security" | "shares" | "price" | "value" | "ccy";
14
14
 
15
15
  export interface InsiderColumn extends DataTableColumn {
16
16
  id: InsiderColumnId;
17
17
  /** The type column has room for the 10b5-1 marker. */
18
18
  plan?: boolean;
19
+ /**
20
+ * The currency the CCY column names, and that Price and Value are written
21
+ * in without a sign, as PF writes LAST beside CCY. Unset when the amounts
22
+ * keep their $.
23
+ */
24
+ currency?: string;
19
25
  }
20
26
 
21
27
  /** One table row: a Form 4 line, or a filing without one (loading, unread, a disclosure). */
@@ -102,12 +108,21 @@ export function formatInsiderShares(value: number | null): string {
102
108
  return value == null ? "—" : formatCompact(value);
103
109
  }
104
110
 
105
- export function formatInsiderPrice(value: number | null): string {
106
- return value == null ? "—" : formatCurrency(value);
111
+ /** A Form 4 price: $412.50, or 412.50 when a CCY column names its currency. */
112
+ export function formatInsiderPrice(value: number | null, currency?: string): string {
113
+ if (value == null) return "—";
114
+ return currency ? formatNumber(value, 2) : formatCurrency(value);
107
115
  }
108
116
 
109
- export function formatInsiderValue(value: number | null): string {
110
- return value == null ? "—" : `$${formatCompact(value)}`;
117
+ /** A Form 4 value: $1.2M, or 1.2M when a CCY column names its currency. */
118
+ export function formatInsiderValue(value: number | null, currency?: string): string {
119
+ if (value == null) return "—";
120
+ return currency ? formatCompact(value) : `$${formatCompact(value)}`;
121
+ }
122
+
123
+ /** What a row's CCY cell says: the currency of its price and value, or a dash for a filing without either. */
124
+ export function insiderCurrencyText(row: Pick<InsiderTableRow, "price" | "value">, currency: string): string {
125
+ return row.price == null && row.value == null ? "—" : currency;
111
126
  }
112
127
 
113
128
  /** The quieter words after the type: "4/A" always, "10b5-1" when the column has room. */
@@ -136,9 +151,11 @@ function fitWidth(texts: readonly string[], label: string, max: number): number
136
151
  * When the pane runs short, the role goes first, then the price, the
137
152
  * 10b5-1 marker, the security and the share count, and only then does the name
138
153
  * column shrink below 16 cells, so a narrow pane still reads date, name,
139
- * type and value.
154
+ * type and value. With `currency` (Form 4 amounts read by someone whose base
155
+ * currency is not the dollar), CCY follows Value and is kept with it, as PF
156
+ * shows CCY once a row is in another currency than the totals.
140
157
  */
141
- export function buildInsiderColumns(width: number, rows: readonly InsiderTableRow[]): InsiderColumn[] {
158
+ export function buildInsiderColumns(width: number, rows: readonly InsiderTableRow[], currency?: string): InsiderColumn[] {
142
159
  const anyPlan = rows.some((row) => row.plan);
143
160
  const anySecurity = rows.some((row) => row.security);
144
161
  const names = rows.map((row) => row.name);
@@ -150,8 +167,9 @@ export function buildInsiderColumns(width: number, rows: readonly InsiderTableRo
150
167
  typePlan: fitWidth(rows.map((row) => insiderTypeText(row, true)), "Type", 19),
151
168
  security: fitWidth(rows.map((row) => row.security ?? ""), "Security", 12),
152
169
  shares: fitWidth(rows.map((row) => formatInsiderShares(row.shares)), "Shares", 9),
153
- price: fitWidth(rows.map((row) => formatInsiderPrice(row.price)), "Price", 10),
154
- value: fitWidth(rows.map((row) => formatInsiderValue(row.value)), "Value", 9),
170
+ price: fitWidth(rows.map((row) => formatInsiderPrice(row.price, currency)), "Price", 10),
171
+ value: fitWidth(rows.map((row) => formatInsiderValue(row.value, currency)), "Value", 9),
172
+ ccy: currency ? fitWidth(rows.map((row) => insiderCurrencyText(row, currency)), "CCY", 4) : 0,
155
173
  };
156
174
  const show = { role: true, price: true, plan: anyPlan, security: anySecurity, shares: true };
157
175
  let insider = widths.insider;
@@ -165,6 +183,7 @@ export function buildInsiderColumns(width: number, rows: readonly InsiderTableRo
165
183
  show.shares ? widths.shares : 0,
166
184
  show.price ? widths.price : 0,
167
185
  widths.value,
186
+ widths.ccy,
168
187
  ].reduce((sum, columnWidth) => sum + (columnWidth > 0 ? columnWidth + COLUMN_GAP : 0), 0);
169
188
  const shrinkInsider = (floor: number) => {
170
189
  insider = Math.max(Math.min(insider, floor), insider - Math.max(0, total() - available));
@@ -187,10 +206,11 @@ export function buildInsiderColumns(width: number, rows: readonly InsiderTableRo
187
206
  { id: "type", label: "Type", width: typeWidth, align: "left", plan: show.plan },
188
207
  ...(show.security ? [{ id: "security" as const, label: "Security", width: widths.security, align: "left" as const }] : []),
189
208
  ...(show.shares ? [{ id: "shares" as const, label: "Shares", width: widths.shares, align: "right" as const }] : []),
190
- ...(show.price ? [{ id: "price" as const, label: "Price", width: widths.price, align: "right" as const }] : []),
191
- { id: "value", label: "Value", width: widths.value, align: "right" },
209
+ ...(show.price ? [{ id: "price" as const, label: "Price", width: widths.price, align: "right" as const, currency }] : []),
210
+ { id: "value", label: "Value", width: widths.value, align: "right", currency },
211
+ ...(currency ? [{ id: "ccy" as const, label: "CCY", width: widths.ccy, align: "left" as const, currency }] : []),
192
212
  ];
193
- // Spare cells spread over the columns, so the value column ends at the
213
+ // Spare cells spread over the columns, so the last column ends at the
194
214
  // pane's edge in the terminal as it does on the desktop.
195
215
  const used = columns.reduce((sum, column) => sum + column.width + COLUMN_GAP, 0);
196
216
  const spare = Math.max(0, available - used);
@@ -222,5 +242,7 @@ export function compareInsiderRows(a: InsiderTableRow, b: InsiderTableRow, colum
222
242
  case "shares": return compareNullable(a.shares, b.shares);
223
243
  case "price": return compareNullable(a.price, b.price);
224
244
  case "value": return compareNullable(a.value, b.value);
245
+ // Every amount is in one currency: rows with one before filings without, as the CCY cells read.
246
+ case "ccy": return Number(a.price == null && a.value == null) - Number(b.price == null && b.value == null);
225
247
  }
226
248
  }
@@ -9,6 +9,7 @@ import {
9
9
  formatInsiderPrice,
10
10
  formatInsiderShares,
11
11
  formatInsiderValue,
12
+ insiderCurrencyText,
12
13
  insiderTypeMarkers,
13
14
  insiderTypeText,
14
15
  type InsiderColumn,
@@ -72,8 +73,10 @@ export function renderInsiderCell(
72
73
  case "shares":
73
74
  return { text: formatInsiderShares(row.shares), value: row.shares, color: figureColor };
74
75
  case "price":
75
- return { text: formatInsiderPrice(row.price), value: row.price, color: figureColor };
76
+ return { text: formatInsiderPrice(row.price, column.currency), value: row.price, color: figureColor };
76
77
  case "value":
77
- return { text: formatInsiderValue(row.value), value: row.value, color: figureColor };
78
+ return { text: formatInsiderValue(row.value, column.currency), value: row.value, color: figureColor };
79
+ case "ccy":
80
+ return { text: insiderCurrencyText(row, column.currency ?? ""), color: colors.textMuted };
78
81
  }
79
82
  }
@@ -0,0 +1,145 @@
1
+ import { getPublishedUsEquityCalendarDay, getPublishedUsEquitySession } from "../../../market-data/published-us-sessions";
2
+ import { zonedDateKey, zonedWallClockToUtcMs } from "../../../utils/zoned-date-time";
3
+ import type { MacroEventKind, MacroRelease } from "../macro-day/releases";
4
+ import type { DatedValue, IvHistoryModel, IvLookback } from "./model";
5
+
6
+ /** One release and the readings a set number of sessions from it. */
7
+ interface ReleaseOffsetRow {
8
+ kind: MacroEventKind;
9
+ /** The release's New York calendar day. */
10
+ releaseDate: string;
11
+ /** Sessions from the release: 0 is the first session on or after its day, -6 the sixth session before that. */
12
+ offset: number;
13
+ /** The session the readings are of. */
14
+ date: string;
15
+ iv30: number | null;
16
+ iv90: number | null;
17
+ iv365: number | null;
18
+ hv: number | null;
19
+ spread: number | null;
20
+ /** 25-delta put IV minus call IV, and the risk reversal, call minus put. */
21
+ skew25d: number | null;
22
+ rr25d: number | null;
23
+ }
24
+
25
+ export interface ReleaseOffsets {
26
+ rows: ReleaseOffsetRow[];
27
+ /** Releases whose session at the offset has not closed or has no closing reading yet, oldest first. */
28
+ pending: string[];
29
+ /** Releases inside the stored history whose session at the offset is before the lookback. */
30
+ beforeLookback: number;
31
+ /** Releases before the IV history starts. */
32
+ beforeHistory: number;
33
+ /** The first session of the IV history read, when there is one. */
34
+ historyStart: string | null;
35
+ }
36
+
37
+ const NEW_YORK = "America/New_York";
38
+ const day = (date: Date) => date.toISOString().slice(0, 10);
39
+ const byDay = (points: readonly DatedValue[]) => new Map(points.map((point) => [day(point.date), point.value]));
40
+ const nextDay = (date: string) => new Date(Date.parse(`${date}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
41
+
42
+ /** Whether `date`'s regular session has closed at `now`: an earlier New York day, or today from its close on. */
43
+ function sessionClosed(date: string, now: number): boolean {
44
+ const today = zonedDateKey(now, NEW_YORK);
45
+ if (date !== today) return date < today;
46
+ const session = getPublishedUsEquitySession("NYSE", date);
47
+ const [year, month, dayOfMonth] = date.split("-").map(Number) as [number, number, number];
48
+ return now >= (session?.kind === "session" ? session.close : zonedWallClockToUtcMs(NEW_YORK, year, month, dayOfMonth, 16, 0, 0));
49
+ }
50
+
51
+ /** The US equity sessions after `after` through the first on or after `until`; null where the published calendar ends. */
52
+ function calendarSessions(after: string, until: string): string[] | null {
53
+ const sessions: string[] = [];
54
+ for (let date = nextDay(after); ; date = nextDay(date)) {
55
+ const kind = getPublishedUsEquityCalendarDay("NYSE", date);
56
+ if (kind == null) return null;
57
+ if (kind !== "session") continue;
58
+ sessions.push(date);
59
+ if (date >= until) return sessions;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * One row per release of `kind`, with the readings on the session `offset`
65
+ * sessions from it (the "week before FOMC" is `-5` or `-6`), oldest first.
66
+ * Offset 0 is the first session on or after the release day, so a release on
67
+ * a day with no session counts from the next one. Sessions are the history's
68
+ * own, from the first IV reading in the lookback through the last closed
69
+ * session read (`readThrough`, or the last IV reading), and past that the
70
+ * published exchange calendar: an upcoming release whose session at a
71
+ * negative offset has closed and been read gets its row. A release whose
72
+ * session at the offset has not closed or has no reading yet is pending; one
73
+ * whose session falls before the history read is counted, before the lookback
74
+ * or before the history.
75
+ */
76
+ export function releaseOffsetRows(
77
+ model: Pick<IvHistoryModel, "iv30" | "iv90" | "iv365" | "hv" | "spread" | "skew" | "since">,
78
+ releases: readonly MacroRelease[],
79
+ kind: MacroEventKind,
80
+ offset: number,
81
+ { lookback = "ALL", readThrough = null, now = Date.now() }: { lookback?: IvLookback; readThrough?: string | null; now?: number } = {},
82
+ ): ReleaseOffsets {
83
+ const result: ReleaseOffsets = { rows: [], pending: [], beforeLookback: 0, beforeHistory: 0, historyStart: null };
84
+ // Sessions from the first IV reading on: realized volatility alone, before it or without it, is no IV row.
85
+ const first = model.iv30[0] ? day(model.iv30[0].date) : null;
86
+ if (!first) return result;
87
+ const lastReading = day(model.iv30.at(-1)!.date);
88
+ const through = readThrough && readThrough > lastReading ? readThrough : lastReading;
89
+ // A session counts once it has closed and been read: today's bar before the close, or a session the IV history has not reached, is no reading.
90
+ const known = [...new Set([...model.iv30, ...model.hv].map((point) => day(point.date)))]
91
+ .filter((session) => session >= first && session <= through && sessionClosed(session, now)).sort();
92
+ if (known.length === 0) return result;
93
+ result.historyStart = known[0]!;
94
+ // The lookback cut a longer stored history: a release before it is outside the lookback, not before the history.
95
+ const trimmed = lookback !== "ALL" && model.since != null && model.since < first;
96
+ const before = (release: MacroRelease) => {
97
+ if (trimmed && release.date >= model.since!) result.beforeLookback += 1;
98
+ else result.beforeHistory += 1;
99
+ };
100
+ const iv30 = byDay(model.iv30), iv90 = byDay(model.iv90), iv365 = byDay(model.iv365);
101
+ const hv = byDay(model.hv), spread = byDay(model.spread), skew = byDay(model.skew);
102
+ for (const release of releases) {
103
+ if (release.kind !== kind) continue;
104
+ if (release.date < known[0]!) {
105
+ before(release);
106
+ continue;
107
+ }
108
+ const anchor = known.findIndex((session) => session >= release.date);
109
+ let index: number;
110
+ if (anchor >= 0) index = anchor + offset;
111
+ else {
112
+ // After the last session read: count on through the exchange calendar to the release's own session.
113
+ const ahead = calendarSessions(known.at(-1)!, release.date);
114
+ if (!ahead) {
115
+ result.pending.push(release.date);
116
+ continue;
117
+ }
118
+ index = known.length + ahead.length - 1 + offset;
119
+ }
120
+ if (index < 0) {
121
+ before(release);
122
+ continue;
123
+ }
124
+ if (index >= known.length) {
125
+ result.pending.push(release.date);
126
+ continue;
127
+ }
128
+ const date = known[index]!;
129
+ const skewValue = skew.get(date) ?? null;
130
+ result.rows.push({
131
+ kind, releaseDate: release.date, offset, date,
132
+ iv30: iv30.get(date) ?? null, iv90: iv90.get(date) ?? null, iv365: iv365.get(date) ?? null,
133
+ hv: hv.get(date) ?? null, spread: spread.get(date) ?? null,
134
+ skew25d: skewValue, rr25d: skewValue == null ? null : -skewValue,
135
+ });
136
+ }
137
+ return result;
138
+ }
139
+
140
+ /** "6 sessions before", "release day", "1 session after". */
141
+ export function offsetText(offset: number): string {
142
+ if (offset === 0) return "release day";
143
+ const sessions = `${Math.abs(offset)} session${Math.abs(offset) === 1 ? "" : "s"}`;
144
+ return offset < 0 ? `${sessions} before` : `${sessions} after`;
145
+ }
@@ -12,6 +12,9 @@ export const VCA_DEFINITIONS = [
12
12
  "VIX is Cboe's 30-day S&P 500 implied vol from the whole SPX strike strip, skew included; IV30 here is at the money, so VIX usually reads higher.",
13
13
  ] as const;
14
14
 
15
+ /** Words for implied volatility that are no function's code: `fn IVOL` names HIVG and OVDV, which list them as `suggestFor`. */
16
+ export const IMPLIED_VOL_WORDS = ["IVOL", "IV", "OVOL", "IMPVOL", "IMPLIEDVOL"];
17
+
15
18
  /**
16
19
  * A skew column's header names its sign, since the conventions differ: the
17
20
  * 25-delta skew is put minus call (HIVG, VCA), the risk reversal call minus