gloomberb 0.13.1 → 0.13.2

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 (92) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/index.test.ts +34 -0
  3. package/src/api-client/request.ts +4 -1
  4. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  5. package/src/cli/pane-functions/cloud-session.ts +38 -0
  6. package/src/cli/pane-functions/index.ts +5 -1
  7. package/src/cli/pane-functions/screenshot.ts +2 -19
  8. package/src/components/command-bar/assist/runtime.ts +38 -30
  9. package/src/components/command-bar/surface/assist.test.tsx +43 -0
  10. package/src/news/types.ts +1 -0
  11. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  12. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  13. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  14. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  15. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  16. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  17. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  18. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  19. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  20. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  21. package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
  22. package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
  23. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  24. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  25. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  26. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  27. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  28. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  29. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  30. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  31. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  32. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  33. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  34. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  35. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  36. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  37. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  38. package/src/plugins/builtin/earnings/headless.ts +162 -0
  39. package/src/plugins/builtin/earnings/index.tsx +2 -0
  40. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  41. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  42. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  43. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  44. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  45. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
  46. package/src/plugins/builtin/holders/client.ts +29 -0
  47. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  48. package/src/plugins/builtin/holders/headless.ts +162 -0
  49. package/src/plugins/builtin/holders/index.tsx +14 -8
  50. package/src/plugins/builtin/holders/pane.tsx +7 -1
  51. package/src/plugins/builtin/insider/client.ts +50 -0
  52. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  53. package/src/plugins/builtin/insider/headless.ts +117 -0
  54. package/src/plugins/builtin/insider/index.tsx +21 -50
  55. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  56. package/src/plugins/builtin/insider/model.ts +72 -0
  57. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  58. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  59. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  60. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  61. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  62. package/src/plugins/builtin/news/headless.test.ts +138 -0
  63. package/src/plugins/builtin/news/headless.ts +190 -0
  64. package/src/plugins/builtin/news/index.tsx +13 -9
  65. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  66. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  67. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  68. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  69. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  70. package/src/plugins/builtin/research/client.ts +12 -0
  71. package/src/plugins/builtin/research/headless.test.ts +93 -0
  72. package/src/plugins/builtin/research/headless.ts +161 -0
  73. package/src/plugins/builtin/research/index.tsx +28 -18
  74. package/src/plugins/builtin/sec/client.ts +16 -0
  75. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  76. package/src/plugins/builtin/sec/headless.ts +69 -0
  77. package/src/plugins/builtin/sec/index.tsx +20 -75
  78. package/src/plugins/builtin/sec/model.ts +89 -0
  79. package/src/plugins/builtin/short-interest/client.ts +5 -2
  80. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  81. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  82. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  83. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  84. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  85. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  86. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  87. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  88. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  89. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  90. package/src/plugins/prediction-markets/headless.ts +255 -0
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/sources/gloomberb-cloud/news.ts +1 -0
@@ -0,0 +1,72 @@
1
+ import type { SecFilingItem } from "../../../types/data-provider";
2
+ import { formatCompact, formatCurrency } from "../../../utils/format";
3
+ import {
4
+ transactionTypeLabel,
5
+ type InsiderTransaction,
6
+ } from "./insider-data";
7
+
8
+ const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
9
+
10
+ export interface ParsedInsiderFiling {
11
+ filing: SecFilingItem;
12
+ transaction: InsiderTransaction | null;
13
+ isLoading: boolean;
14
+ }
15
+
16
+ /** Null while filings are still parsing, so the pane never claims no activity early. */
17
+ export function buildInsiderSummary(
18
+ parsed: ParsedInsiderFiling[],
19
+ now = Date.now(),
20
+ ): string | null {
21
+ if (parsed.some(({ isLoading }) => isLoading)) return null;
22
+ const cutoff = new Date(now - NINETY_DAYS_MS);
23
+ let buyShares = 0;
24
+ let sellShares = 0;
25
+ let buyValue = 0;
26
+ let sellValue = 0;
27
+
28
+ for (const { transaction } of parsed) {
29
+ if (!transaction) continue;
30
+ const txDate = transaction.filingDate instanceof Date
31
+ ? transaction.filingDate
32
+ : new Date(transaction.filingDate);
33
+ if (Number.isNaN(txDate.getTime()) || txDate < cutoff) continue;
34
+ if (transaction.transactionType === "P") {
35
+ buyShares += transaction.shares;
36
+ buyValue += transaction.totalValue ?? 0;
37
+ } else if (transaction.transactionType === "S") {
38
+ sellShares += transaction.shares;
39
+ sellValue += transaction.totalValue ?? 0;
40
+ }
41
+ }
42
+
43
+ if (buyShares === 0 && sellShares === 0) return "No buy/sell activity in last 90 days.";
44
+
45
+ const parts: string[] = [];
46
+ if (buyShares > 0) parts.push(`Bought ${formatCompact(buyShares)} shares (${formatCurrency(buyValue)})`);
47
+ if (sellShares > 0) parts.push(`Sold ${formatCompact(sellShares)} shares (${formatCurrency(sellValue)})`);
48
+ return parts.join(" | ");
49
+ }
50
+
51
+ export function buildInsiderRows(parsed: readonly ParsedInsiderFiling[]) {
52
+ return parsed.map(({ filing, transaction, isLoading }) => ({
53
+ filingDate: filing.filingDate instanceof Date
54
+ ? filing.filingDate.toISOString()
55
+ : String(filing.filingDate),
56
+ transactionDate: transaction?.filingDate instanceof Date
57
+ ? transaction.filingDate.toISOString()
58
+ : transaction?.filingDate ? String(transaction.filingDate) : null,
59
+ insider: transaction?.reportedName ?? null,
60
+ title: transaction?.title ?? null,
61
+ side: transaction ? transactionTypeLabel(transaction.transactionType) : null,
62
+ transactionCode: transaction?.transactionType ?? null,
63
+ shares: transaction?.shares ?? null,
64
+ pricePerShare: transaction?.pricePerShare ?? null,
65
+ totalValue: transaction?.totalValue ?? null,
66
+ sharesOwnedAfter: transaction?.sharesOwned ?? null,
67
+ form: filing.form,
68
+ accessionNumber: filing.accessionNumber,
69
+ url: filing.filingUrl,
70
+ status: isLoading ? "loading" : transaction ? "parsed" : "unavailable",
71
+ }));
72
+ }
@@ -256,6 +256,21 @@ async function fetchUpcomingIpos(): Promise<IPORecord[]> {
256
256
  return parseCalendarRecords(flat);
257
257
  }
258
258
 
259
+ export function formatIpoDate(date: Date): string {
260
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
261
+ const day = String(date.getUTCDate()).padStart(2, "0");
262
+ return `${date.getUTCFullYear()}-${month}-${day}`;
263
+ }
264
+
265
+ export function matchesIpoRecord(record: IPORecord, query: string): boolean {
266
+ if (!query) return true;
267
+ const normalized = query.toLowerCase();
268
+ return record.ticker.toLowerCase().includes(normalized)
269
+ || record.companyName.toLowerCase().includes(normalized)
270
+ || (record.exchange?.toLowerCase().includes(normalized) ?? false)
271
+ || record.status.includes(normalized);
272
+ }
273
+
259
274
  function dedupeAndSort(records: IPORecord[]): IPORecord[] {
260
275
  const byTicker = new Map<string, IPORecord>();
261
276
  const statusOrder: Record<IPOStatus, number> = { upcoming: 0, priced: 1, trading: 2 };
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { HeadlessPaneContext, HeadlessPaneLoadArgs } from "../../../types/plugin";
3
+ import { createIpoCalendarHeadless } from "./headless";
4
+ import type { IPORecord } from "./types";
5
+
6
+ const records: IPORecord[] = [
7
+ {
8
+ ticker: "ALFA",
9
+ companyName: "Alpha Labs",
10
+ date: new Date("2026-09-10T00:00:00.000Z"),
11
+ status: "upcoming",
12
+ exchange: "NASDAQ",
13
+ offerSize: 120_000_000,
14
+ priceRange: [18, 20],
15
+ pricedPrice: null,
16
+ shares: 6_000_000,
17
+ closePrice: null,
18
+ change1D: null,
19
+ },
20
+ {
21
+ ticker: "BETA",
22
+ companyName: "Beta Systems",
23
+ date: new Date("2026-09-03T00:00:00.000Z"),
24
+ status: "trading",
25
+ exchange: "NYSE",
26
+ offerSize: null,
27
+ priceRange: null,
28
+ pricedPrice: 24,
29
+ shares: null,
30
+ closePrice: 30,
31
+ change1D: 25,
32
+ },
33
+ ];
34
+
35
+ function args(
36
+ argument: string | null = null,
37
+ options: Record<string, string | number | boolean> = { status: "all", limit: 50 },
38
+ ): HeadlessPaneLoadArgs {
39
+ return {
40
+ rawArgument: argument ?? "",
41
+ argument,
42
+ symbols: [],
43
+ options,
44
+ };
45
+ }
46
+
47
+ const context = {} as HeadlessPaneContext;
48
+
49
+ describe("IPO calendar headless", () => {
50
+ test("maps IPO records into structured rows", async () => {
51
+ const definition = createIpoCalendarHeadless({
52
+ loadCalendar: async () => ({
53
+ records,
54
+ fetchedAt: 123,
55
+ stale: false,
56
+ errors: [],
57
+ }),
58
+ });
59
+
60
+ const result = await definition.load(args("alpha"), context);
61
+
62
+ expect(result.rows).toEqual([{
63
+ ticker: "ALFA",
64
+ company: "Alpha Labs",
65
+ date: "2026-09-10",
66
+ status: "upcoming",
67
+ exchange: "NASDAQ",
68
+ offerSize: 120_000_000,
69
+ priceLow: 18,
70
+ priceHigh: 20,
71
+ pricedPrice: null,
72
+ shares: 6_000_000,
73
+ closePrice: null,
74
+ change1D: null,
75
+ }]);
76
+ expect(result.metadata).toMatchObject({ total: 1, returned: 1, truncated: false });
77
+ });
78
+
79
+ test("applies status and limit options", async () => {
80
+ const definition = createIpoCalendarHeadless({
81
+ loadCalendar: async () => ({ records, fetchedAt: 123, stale: false, errors: [] }),
82
+ });
83
+
84
+ const result = await definition.load(args(null, { status: "trading", limit: 1 }), context);
85
+
86
+ expect(result.rows.map((row) => row.ticker)).toEqual(["BETA"]);
87
+ expect(result.metadata).toMatchObject({ status: "trading", returned: 1 });
88
+ });
89
+ });
@@ -0,0 +1,127 @@
1
+ import type {
2
+ HeadlessPaneDefinition,
3
+ HeadlessPaneLoadArgs,
4
+ HeadlessRowsResult,
5
+ } from "../../../types/plugin";
6
+ import { formatIpoDate, matchesIpoRecord } from "./client";
7
+ import { loadIpoCalendar, type IpoCalendarResult } from "./cache";
8
+ import type { IPORecord, IPOStatus } from "./types";
9
+
10
+ const IPO_COLUMNS = [
11
+ { key: "ticker", header: "Ticker" },
12
+ { key: "company", header: "Company" },
13
+ { key: "date", header: "Date" },
14
+ { key: "status", header: "Status" },
15
+ { key: "exchange", header: "Exchange" },
16
+ { key: "offerSize", header: "Offer", align: "right" as const },
17
+ { key: "priceLow", header: "Price low", align: "right" as const },
18
+ { key: "priceHigh", header: "Price high", align: "right" as const },
19
+ { key: "pricedPrice", header: "Priced", align: "right" as const },
20
+ { key: "shares", header: "Shares", align: "right" as const },
21
+ { key: "closePrice", header: "Close", align: "right" as const },
22
+ { key: "change1D", header: "1D %", align: "right" as const },
23
+ ];
24
+
25
+ export interface IpoCalendarHeadlessDependencies {
26
+ loadCalendar(): Promise<IpoCalendarResult>;
27
+ }
28
+
29
+ const defaultDependencies: IpoCalendarHeadlessDependencies = {
30
+ loadCalendar: () => loadIpoCalendar(false),
31
+ };
32
+
33
+ function statusOption(args: HeadlessPaneLoadArgs): IPOStatus | "all" {
34
+ return String(args.options.status ?? "all") as IPOStatus | "all";
35
+ }
36
+
37
+ function toHeadlessRow(record: IPORecord) {
38
+ return {
39
+ ticker: record.ticker,
40
+ company: record.companyName,
41
+ date: formatIpoDate(record.date),
42
+ status: record.status,
43
+ exchange: record.exchange,
44
+ offerSize: record.offerSize,
45
+ priceLow: record.priceRange?.[0] ?? null,
46
+ priceHigh: record.priceRange?.[1] ?? null,
47
+ pricedPrice: record.pricedPrice,
48
+ shares: record.shares,
49
+ closePrice: record.closePrice,
50
+ change1D: record.change1D,
51
+ };
52
+ }
53
+
54
+ export function projectIpoCalendarHeadless(
55
+ result: IpoCalendarResult,
56
+ args: HeadlessPaneLoadArgs,
57
+ ): HeadlessRowsResult {
58
+ const query = typeof args.argument === "string" ? args.argument.trim() : "";
59
+ const status = statusOption(args);
60
+ const limit = Number(args.options.limit ?? 50);
61
+ const matching = result.records.filter((record) => (
62
+ matchesIpoRecord(record, query)
63
+ && (status === "all" || record.status === status)
64
+ ));
65
+ const rows = matching.slice(0, limit).map(toHeadlessRow);
66
+
67
+ return {
68
+ columns: IPO_COLUMNS,
69
+ rows,
70
+ errors: result.errors.length > 0 ? result.errors : undefined,
71
+ metadata: {
72
+ fetchedAt: result.fetchedAt,
73
+ stale: result.stale,
74
+ total: matching.length,
75
+ returned: rows.length,
76
+ truncated: rows.length < matching.length,
77
+ query: query || null,
78
+ status,
79
+ },
80
+ };
81
+ }
82
+
83
+ export function createIpoCalendarHeadless(
84
+ dependencies: IpoCalendarHeadlessDependencies = defaultDependencies,
85
+ ): HeadlessPaneDefinition<"rows"> {
86
+ return {
87
+ shape: "rows",
88
+ argument: {
89
+ kind: "free-text",
90
+ placeholder: "company or ticker",
91
+ description: "Optional company, ticker, exchange, or status filter.",
92
+ optional: true,
93
+ },
94
+ options: [
95
+ {
96
+ key: "status",
97
+ description: "IPO stage to include.",
98
+ type: "enum",
99
+ values: [
100
+ { value: "all" },
101
+ { value: "upcoming" },
102
+ { value: "priced" },
103
+ { value: "trading" },
104
+ ],
105
+ defaultValue: "all",
106
+ },
107
+ {
108
+ key: "limit",
109
+ description: "Maximum IPO rows to return.",
110
+ type: "integer",
111
+ defaultValue: 50,
112
+ minimum: 1,
113
+ maximum: 200,
114
+ },
115
+ ],
116
+ columns: IPO_COLUMNS,
117
+ describe: (args) => {
118
+ const status = statusOption(args);
119
+ return status === "all" ? "IPO Calendar" : `IPO Calendar | ${status}`;
120
+ },
121
+ async load(args) {
122
+ return projectIpoCalendarHeadless(await dependencies.loadCalendar(), args);
123
+ },
124
+ };
125
+ }
126
+
127
+ export const ipoCalendarHeadless = createIpoCalendarHeadless();
@@ -6,6 +6,7 @@ import {
6
6
  } from "./client";
7
7
  import { attachIpoCalendarPersistence, resetIpoCalendarPersistence } from "./cache";
8
8
  import { IPOCalendarPane } from "./pane";
9
+ import { ipoCalendarHeadless } from "./headless";
9
10
  import { IPO_CALENDAR_PANE_ID } from "./types";
10
11
 
11
12
  let disposeConnection: (() => void) | null = null;
@@ -60,6 +61,7 @@ export const ipoCalendarModule: PluginModule = {
60
61
  "debut",
61
62
  ],
62
63
  shortcut: { prefix: "IPO" },
64
+ headless: ipoCalendarHeadless,
63
65
  createInstance: () => ({ placement: "floating" }),
64
66
  },
65
67
  ],
@@ -2,6 +2,7 @@ import type { DataTableColumn } from "../../../components";
2
2
  import { colors } from "../../../theme/colors";
3
3
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
4
4
  import type { IPORecord, IPOStatus } from "./types";
5
+ import { formatIpoDate, matchesIpoRecord } from "./client";
5
6
 
6
7
  type IPOColumnId =
7
8
  | "ticker"
@@ -37,11 +38,7 @@ export function statusColor(status: IPOStatus): string {
37
38
  }
38
39
  }
39
40
 
40
- export function formatDate(date: Date): string {
41
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
42
- const day = String(date.getUTCDate()).padStart(2, "0");
43
- return `${date.getUTCFullYear()}-${month}-${day}`;
44
- }
41
+ export const formatDate = formatIpoDate;
45
42
 
46
43
  export function formatOfferSize(value: number | null): string {
47
44
  if (value == null) return "—";
@@ -133,13 +130,4 @@ export function nextSortPreference(current: IPOSortPreference, columnId: string)
133
130
  return DEFAULT_SORT_PREFERENCE;
134
131
  }
135
132
 
136
- export function matchesSearch(record: IPORecord, query: string): boolean {
137
- if (!query) return true;
138
- const q = query.toLowerCase();
139
- return (
140
- record.ticker.toLowerCase().includes(q)
141
- || record.companyName.toLowerCase().includes(q)
142
- || (record.exchange?.toLowerCase().includes(q) ?? false)
143
- || record.status.includes(q)
144
- );
145
- }
133
+ export const matchesSearch = matchesIpoRecord;
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CloudNewsPayload } from "../../../api-client";
3
+ import type { HeadlessPaneContext, HeadlessPaneLoadArgs } from "../../../types/plugin";
4
+ import type { NewsQuery } from "../../../news/types";
5
+ import {
6
+ createNewsFeedHeadless,
7
+ createTickerNewsHeadless,
8
+ type NewsHeadlessDependencies,
9
+ } from "./headless";
10
+
11
+ function story(
12
+ id: string,
13
+ sentiment: CloudNewsPayload["sentiment"],
14
+ importance: number,
15
+ publishedAt: string,
16
+ ): CloudNewsPayload {
17
+ return {
18
+ id,
19
+ headline: `${id} headline`,
20
+ summary: `${id} summary`,
21
+ category: "markets",
22
+ sentiment,
23
+ sectors: ["technology"],
24
+ firstPublishedAt: publishedAt,
25
+ lastPublishedAt: publishedAt,
26
+ firstSeenAt: publishedAt,
27
+ lastSeenAt: publishedAt,
28
+ primaryUrl: `https://example.com/${id}`,
29
+ primarySource: "Example Wire",
30
+ scores: {
31
+ importance,
32
+ urgency: importance - 1,
33
+ marketImpact: importance - 2,
34
+ novelty: 7,
35
+ confidence: 90,
36
+ },
37
+ flags: { breaking: importance > 80 },
38
+ variantCount: 1,
39
+ sourceCount: 1,
40
+ sources: ["Example Wire"],
41
+ entities: [],
42
+ tickerLinks: [{
43
+ symbol: "AMD",
44
+ exchange: "NASDAQ",
45
+ canonicalTicker: "AMD",
46
+ relationType: "issuer",
47
+ displayTier: "primary",
48
+ confidence: 1,
49
+ relevanceScore: 1,
50
+ }],
51
+ };
52
+ }
53
+
54
+ const stories = [
55
+ story("newer", "positive", 95, "2026-09-04T20:00:00.000Z"),
56
+ story("older", "negative", 70, "2026-09-04T18:00:00.000Z"),
57
+ ];
58
+
59
+ function args(
60
+ symbol: string | null,
61
+ options: Record<string, string | number | boolean>,
62
+ ): HeadlessPaneLoadArgs {
63
+ return {
64
+ rawArgument: symbol ?? "",
65
+ argument: symbol,
66
+ symbols: symbol ? [symbol] : [],
67
+ options,
68
+ };
69
+ }
70
+
71
+ const context = {} as HeadlessPaneContext;
72
+
73
+ function dependencies(seen: NewsQuery[]): NewsHeadlessDependencies {
74
+ return {
75
+ loadNews: async (query) => {
76
+ seen.push(query);
77
+ return { items: stories, nextCursor: "page-2" };
78
+ },
79
+ now: () => new Date("2026-09-04T21:00:00.000Z"),
80
+ };
81
+ }
82
+
83
+ describe("news feed headless", () => {
84
+ test("maps cloud stories into a point-in-time snapshot", async () => {
85
+ const seen: NewsQuery[] = [];
86
+ const definition = createNewsFeedHeadless(dependencies(seen));
87
+
88
+ const result = await definition.load(
89
+ args(null, { sentiment: "any", minImportance: 0, limit: 50 }),
90
+ context,
91
+ );
92
+
93
+ expect(result.asOf).toBe("2026-09-04T21:00:00.000Z");
94
+ expect(result.items[0]).toMatchObject({
95
+ id: "newer",
96
+ headline: "newer headline",
97
+ tickers: ["AMD"],
98
+ sentiment: "positive",
99
+ importance: 95,
100
+ breaking: true,
101
+ sourceCount: 1,
102
+ });
103
+ expect(seen[0]).toMatchObject({ feed: "latest", limit: 50 });
104
+ });
105
+
106
+ test("applies sentiment and importance options", async () => {
107
+ const definition = createNewsFeedHeadless(dependencies([]));
108
+
109
+ const result = await definition.load(
110
+ args(null, { sentiment: "positive", minImportance: 90, limit: 50 }),
111
+ context,
112
+ );
113
+
114
+ expect(result.items.map((item) => item.id)).toEqual(["newer"]);
115
+ });
116
+ });
117
+
118
+ describe("ticker news headless", () => {
119
+ test("requests the ticker feed and applies the row limit", async () => {
120
+ const seen: NewsQuery[] = [];
121
+ const definition = createTickerNewsHeadless(dependencies(seen));
122
+
123
+ const result = await definition.load(
124
+ args("AMD:XNAS", { sentiment: "any", minImportance: 0, limit: 1 }),
125
+ context,
126
+ );
127
+
128
+ expect(seen[0]).toMatchObject({
129
+ feed: "ticker",
130
+ ticker: "AMD",
131
+ exchange: "NASDAQ",
132
+ tickerTier: "primary",
133
+ limit: 1,
134
+ });
135
+ expect(result.items).toHaveLength(1);
136
+ expect(result.metadata).toMatchObject({ truncated: true });
137
+ });
138
+ });
@@ -0,0 +1,190 @@
1
+ import type { CloudNewsListResponse } from "../../../api-client";
2
+ import type {
3
+ HeadlessPaneContext,
4
+ HeadlessPaneDefinition,
5
+ HeadlessPaneLoadArgs,
6
+ HeadlessSnapshotResult,
7
+ } from "../../../types/plugin";
8
+ import type { NewsArticle, NewsQuery } from "../../../news/types";
9
+ import {
10
+ cloudNewsParams,
11
+ mapCloudNewsArticle,
12
+ } from "../../../sources/gloomberb-cloud/news";
13
+ import { parsePublicTickerKey } from "../../../utils/exchanges";
14
+
15
+ const NEWS_COLUMNS = [
16
+ { key: "publishedAt", header: "Published" },
17
+ { key: "source", header: "Source" },
18
+ { key: "headline", header: "Headline" },
19
+ { key: "tickers", header: "Tickers" },
20
+ { key: "sentiment", header: "Sentiment" },
21
+ { key: "importance", header: "Score", align: "right" as const },
22
+ ];
23
+
24
+ export interface NewsHeadlessDependencies {
25
+ loadNews(
26
+ query: NewsQuery,
27
+ context: HeadlessPaneContext,
28
+ ): Promise<CloudNewsListResponse>;
29
+ now(): Date;
30
+ }
31
+
32
+ const defaultDependencies: NewsHeadlessDependencies = {
33
+ loadNews: (query, context) => context.apiClient.getCloudNews(cloudNewsParams(query)),
34
+ now: () => new Date(),
35
+ };
36
+
37
+ function newsRow(article: NewsArticle) {
38
+ return {
39
+ id: article.id,
40
+ publishedAt: article.publishedAt.toISOString(),
41
+ source: article.source,
42
+ headline: article.title,
43
+ summary: article.summary ?? null,
44
+ topic: article.topic,
45
+ topics: article.topics,
46
+ sectors: article.sectors,
47
+ categories: article.categories,
48
+ tickers: article.tickers,
49
+ sentiment: article.sentiment ?? null,
50
+ importance: article.scores.importance,
51
+ urgency: article.scores.urgency,
52
+ marketImpact: article.scores.marketImpact,
53
+ novelty: article.scores.novelty,
54
+ confidence: article.scores.confidence,
55
+ breaking: article.isBreaking,
56
+ developing: article.isDeveloping,
57
+ sourceCount: article.sourceCount ?? article.items?.length ?? 0,
58
+ url: article.url,
59
+ };
60
+ }
61
+
62
+ function selectedSentiment(args: HeadlessPaneLoadArgs): string {
63
+ return String(args.options.sentiment ?? "any");
64
+ }
65
+
66
+ export function projectNewsHeadless(
67
+ response: CloudNewsListResponse,
68
+ args: HeadlessPaneLoadArgs,
69
+ now: Date,
70
+ ): HeadlessSnapshotResult {
71
+ const sentiment = selectedSentiment(args);
72
+ const minImportance = Number(args.options.minImportance ?? 0);
73
+ const limit = Number(args.options.limit ?? 50);
74
+ const matching = response.items
75
+ .map((item) => mapCloudNewsArticle(item, args.symbols[0]))
76
+ .filter((article) => sentiment === "any" || article.sentiment === sentiment)
77
+ .filter((article) => article.importance >= minImportance)
78
+ .sort((left, right) => right.publishedAt.getTime() - left.publishedAt.getTime());
79
+ const items = matching.slice(0, limit).map(newsRow);
80
+
81
+ return {
82
+ asOf: now.toISOString(),
83
+ items,
84
+ metadata: {
85
+ nextCursor: response.nextCursor,
86
+ sentiment,
87
+ minImportance,
88
+ total: matching.length,
89
+ returned: items.length,
90
+ truncated: items.length < matching.length || response.nextCursor != null,
91
+ },
92
+ };
93
+ }
94
+
95
+ function newsOptions() {
96
+ return [
97
+ {
98
+ key: "sentiment",
99
+ description: "Story sentiment to include.",
100
+ type: "enum" as const,
101
+ values: [
102
+ { value: "any" },
103
+ { value: "positive" },
104
+ { value: "neutral" },
105
+ { value: "negative" },
106
+ ],
107
+ defaultValue: "any",
108
+ },
109
+ {
110
+ key: "minImportance",
111
+ aliases: ["importance"],
112
+ description: "Minimum importance score.",
113
+ type: "integer" as const,
114
+ defaultValue: 0,
115
+ minimum: 0,
116
+ maximum: 100,
117
+ },
118
+ {
119
+ key: "limit",
120
+ description: "Maximum stories to return.",
121
+ type: "integer" as const,
122
+ defaultValue: 50,
123
+ minimum: 1,
124
+ maximum: 200,
125
+ },
126
+ ];
127
+ }
128
+
129
+ function queryFor(args: HeadlessPaneLoadArgs, feed: "latest" | "ticker"): NewsQuery {
130
+ const sentiment = selectedSentiment(args);
131
+ const ticker = args.symbols[0] ? parsePublicTickerKey(args.symbols[0]) : null;
132
+ return {
133
+ feed,
134
+ ...(feed === "ticker"
135
+ ? {
136
+ ticker: ticker?.symbol,
137
+ exchange: ticker?.exchange,
138
+ tickerTier: "primary" as const,
139
+ }
140
+ : {}),
141
+ ...(sentiment === "any" ? {} : { sentiment: sentiment as "positive" | "neutral" | "negative" }),
142
+ minImportance: Number(args.options.minImportance ?? 0),
143
+ limit: Number(args.options.limit ?? 50),
144
+ };
145
+ }
146
+
147
+ export function createNewsFeedHeadless(
148
+ dependencies: NewsHeadlessDependencies = defaultDependencies,
149
+ ): HeadlessPaneDefinition<"snapshot"> {
150
+ return {
151
+ shape: "snapshot",
152
+ argument: { kind: "none" },
153
+ options: newsOptions(),
154
+ columns: NEWS_COLUMNS,
155
+ describe: "News Feed",
156
+ async load(args, context) {
157
+ return projectNewsHeadless(
158
+ await dependencies.loadNews(queryFor(args, "latest"), context),
159
+ args,
160
+ dependencies.now(),
161
+ );
162
+ },
163
+ };
164
+ }
165
+
166
+ export function createTickerNewsHeadless(
167
+ dependencies: NewsHeadlessDependencies = defaultDependencies,
168
+ ): HeadlessPaneDefinition<"snapshot"> {
169
+ return {
170
+ shape: "snapshot",
171
+ argument: {
172
+ kind: "ticker",
173
+ placeholder: "ticker",
174
+ description: "Company symbol, optionally qualified as SYMBOL:EXCHANGE.",
175
+ },
176
+ options: newsOptions(),
177
+ columns: NEWS_COLUMNS,
178
+ describe: (args) => `Ticker News | ${String(args.argument)}`,
179
+ async load(args, context) {
180
+ return projectNewsHeadless(
181
+ await dependencies.loadNews(queryFor(args, "ticker"), context),
182
+ args,
183
+ dependencies.now(),
184
+ );
185
+ },
186
+ };
187
+ }
188
+
189
+ export const newsFeedHeadless = createNewsFeedHeadless();
190
+ export const tickerNewsHeadless = createTickerNewsHeadless();