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
@@ -17,6 +17,7 @@ import { useNewsArticleFooter } from "./wire/news/footer";
17
17
  import { usePersistedNewsArticles } from "./wire/persisted-articles";
18
18
  import { useNewsReadState } from "./wire/read-state";
19
19
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
20
+ import { tickerNewsHeadless } from "./headless";
20
21
 
21
22
  const NEWS_ITEM_LIMIT = 50;
22
23
  const DEFAULT_SORT: NewsSortPreference = { columnId: "time", direction: "desc" };
@@ -140,15 +141,18 @@ export const tickerNewsModule: PluginModule = {
140
141
  ],
141
142
 
142
143
  paneTemplates: [
143
- createTickerSurfacePaneTemplate({
144
- id: "ticker-news-pane",
145
- paneId: "ticker-news",
146
- label: "Ticker News",
147
- description: "Company news for the selected ticker.",
148
- keywords: ["company", "ticker", "news", "headlines", "cn"],
149
- shortcut: "CN",
150
- publicShare: true,
151
- }),
144
+ {
145
+ ...createTickerSurfacePaneTemplate({
146
+ id: "ticker-news-pane",
147
+ paneId: "ticker-news",
148
+ label: "Ticker News",
149
+ description: "Company news for the selected ticker.",
150
+ keywords: ["company", "ticker", "news", "headlines", "cn"],
151
+ shortcut: "CN",
152
+ publicShare: true,
153
+ }),
154
+ headless: tickerNewsHeadless,
155
+ },
152
156
  ],
153
157
 
154
158
  setup(ctx) {
@@ -23,6 +23,7 @@ import { NewsPresetPane } from "./news/preset-pane";
23
23
  import { NEWS_QUERY_PRESETS } from "./news/query-presets";
24
24
  import type { NewsColumnId, NewsSortPreference } from "./news/table";
25
25
  import { createRssNewsCapability } from "./rss/source";
26
+ import { newsFeedHeadless } from "../headless";
26
27
 
27
28
  interface NewsPresetPaneConfig {
28
29
  paneKey: string;
@@ -107,7 +108,7 @@ export const newsWireModule: PluginModule = {
107
108
  ],
108
109
  paneTemplates: [
109
110
  { id: "news-top-pane", paneId: "news-top", label: "Top News", description: "Curated top market stories ranked by importance", keywords: ["top", "news", "headlines", "stories"], shortcut: { prefix: "TOP" } },
110
- { id: "news-feed-pane", paneId: "news-feed", label: "News Feed", description: "Chronological market news firehose", keywords: ["news", "feed", "firehose", "wire", "stream"], shortcut: { prefix: "N" } },
111
+ { id: "news-feed-pane", paneId: "news-feed", label: "News Feed", description: "Chronological market news firehose", keywords: ["news", "feed", "firehose", "wire", "stream"], shortcut: { prefix: "N" }, headless: newsFeedHeadless },
111
112
  { id: "news-industry-pane", paneId: "news-industry", label: "Sector News", description: "Market news filtered by sector", keywords: ["news", "industry", "sector", "ni", "filter"], shortcut: { prefix: "NI" } },
112
113
  { id: "news-breaking-pane", paneId: "news-breaking", label: "Breaking News", description: "Breaking and urgent market news", keywords: ["first", "breaking", "urgent", "alert", "flash"], shortcut: { prefix: "FIRST" } },
113
114
  ],
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createTestDataProvider } from "../../../test-support/data-provider";
3
+ import { createDefaultConfig } from "../../../types/config";
4
+ import type { AnalystResearchData } from "../../../types/financials";
5
+ import type { HeadlessPaneContext, HeadlessPaneLoadArgs } from "../../../types/plugin";
6
+ import { createAnalystResearchHeadless } from "./analyst-headless";
7
+
8
+ const data: AnalystResearchData = {
9
+ symbol: "AMD",
10
+ currency: "USD",
11
+ priceTarget: { current: 150, average: 180, low: 120, median: 175, high: 230 },
12
+ recommendationRating: 8.4,
13
+ recommendations: [{ period: "current month", strongBuy: 10, buy: 8, hold: 4, sell: 1 }],
14
+ ratings: [
15
+ { date: "2026-08-20", firm: "Beta", action: "Maintains", current: "Buy", currentPriceTarget: 190 },
16
+ { date: "2026-08-25", firm: "Alpha", action: "Raises", current: "Buy", prior: "Hold", currentPriceTarget: 210, priorPriceTarget: 180 },
17
+ ],
18
+ earningsEstimates: [],
19
+ revenueEstimates: [],
20
+ };
21
+
22
+ function context(): HeadlessPaneContext {
23
+ return {
24
+ marketData: createTestDataProvider(),
25
+ apiClient: {} as HeadlessPaneContext["apiClient"],
26
+ config: createDefaultConfig("/tmp/gloomberb-headless-analyst"),
27
+ signal: new AbortController().signal,
28
+ };
29
+ }
30
+
31
+ function args(overrides: Partial<HeadlessPaneLoadArgs["options"]> = {}): HeadlessPaneLoadArgs {
32
+ return {
33
+ rawArgument: "AMD",
34
+ argument: "AMD",
35
+ symbols: ["AMD"],
36
+ options: { sort: "date", order: "desc", limit: 25, ...overrides },
37
+ };
38
+ }
39
+
40
+ describe("analyst research headless model", () => {
41
+ test("projects summary and ratings while applying sort and limit", async () => {
42
+ const headless = createAnalystResearchHeadless({ loadData: async () => data });
43
+
44
+ const latest = await headless.load(args({ limit: 1 }), context());
45
+ expect(latest.sections[0]?.title).toBe("Summary");
46
+ const entries = "entries" in latest.sections[0]! ? latest.sections[0]!.entries : [];
47
+ expect(entries.slice(0, 2)).toMatchObject([
48
+ { label: "Average target", value: 180 },
49
+ { label: "Target upside", value: 0.2 },
50
+ ]);
51
+ expect(latest.sections[1]).toMatchObject({
52
+ title: "Recent analyst actions",
53
+ rows: [{ firm: "Alpha", currentPriceTarget: 210, priorPriceTarget: 180 }],
54
+ });
55
+
56
+ const byFirm = await headless.load(args({ sort: "firm", order: "desc", limit: 2 }), context());
57
+ const ratings = "rows" in byFirm.sections[1]! ? byFirm.sections[1]!.rows : [];
58
+ expect(ratings.map((row) => row.firm)).toEqual(["Beta", "Alpha"]);
59
+ });
60
+ });
@@ -0,0 +1,172 @@
1
+ import type {
2
+ HeadlessBundleSection,
3
+ HeadlessPaneColumn,
4
+ HeadlessPaneContext,
5
+ HeadlessPaneDefinition,
6
+ HeadlessPaneEntry,
7
+ HeadlessPaneLoadArgs,
8
+ } from "../../../types/plugin";
9
+ import type { AnalystResearchData } from "../../../types/financials";
10
+ import { formatCurrency, formatPercent } from "../../../utils/format";
11
+ import { loadAnalystResearch } from "./client";
12
+ import {
13
+ formatRatingLabel,
14
+ formatRatingTarget,
15
+ latestRecommendation,
16
+ recommendationTotal,
17
+ sortRatingRows,
18
+ targetUpside,
19
+ type RatingColumnId,
20
+ } from "./analyst-model";
21
+
22
+ const RATING_COLUMNS: HeadlessPaneColumn[] = [
23
+ { key: "date", header: "Date" },
24
+ { key: "firm", header: "Firm" },
25
+ { key: "action", header: "Action" },
26
+ { key: "current", header: "Rating" },
27
+ {
28
+ key: "target",
29
+ header: "Target",
30
+ format: (_value, row) => formatRatingTarget(
31
+ row as unknown as AnalystResearchData["ratings"][number],
32
+ String(row.currency ?? "USD"),
33
+ ).trim(),
34
+ },
35
+ { key: "prior", header: "Prior" },
36
+ ];
37
+
38
+ const SORT_COLUMNS: Record<string, RatingColumnId> = {
39
+ date: "date",
40
+ firm: "firm",
41
+ action: "action",
42
+ rating: "current",
43
+ target: "target",
44
+ };
45
+
46
+ function overviewEntries(data: AnalystResearchData): HeadlessPaneEntry[] {
47
+ const target = data.priceTarget;
48
+ const currency = target?.currency ?? data.currency ?? "USD";
49
+ const recommendation = latestRecommendation(data);
50
+ const upside = targetUpside(target);
51
+ return [
52
+ {
53
+ label: "Average target",
54
+ value: target?.average ?? null,
55
+ formatted: target?.average == null ? "-" : formatCurrency(target.average, currency),
56
+ },
57
+ {
58
+ label: "Target upside",
59
+ value: upside ?? null,
60
+ formatted: upside == null ? "-" : formatPercent(upside),
61
+ },
62
+ {
63
+ label: "Target range",
64
+ value: target ? { low: target.low, median: target.median, high: target.high } : null,
65
+ formatted: target
66
+ ? [target.low, target.median, target.high]
67
+ .map((value) => value == null ? "-" : formatCurrency(value, currency))
68
+ .join(" / ")
69
+ : "-",
70
+ },
71
+ {
72
+ label: "Recommendation rating",
73
+ value: data.recommendationRating ?? null,
74
+ formatted: formatRatingLabel(data.recommendationRating),
75
+ },
76
+ { label: "Analysts", value: recommendationTotal(data) },
77
+ {
78
+ label: "Recommendation mix",
79
+ value: recommendation ? {
80
+ period: recommendation.period,
81
+ strongBuy: recommendation.strongBuy ?? 0,
82
+ buy: recommendation.buy ?? 0,
83
+ hold: recommendation.hold ?? 0,
84
+ sell: recommendation.sell ?? 0,
85
+ strongSell: recommendation.strongSell ?? 0,
86
+ } : null,
87
+ formatted: recommendation
88
+ ? `SB ${recommendation.strongBuy ?? 0} B ${recommendation.buy ?? 0} H ${recommendation.hold ?? 0} S ${(recommendation.sell ?? 0) + (recommendation.strongSell ?? 0)}`
89
+ : "-",
90
+ },
91
+ ];
92
+ }
93
+
94
+ export interface AnalystResearchHeadlessDependencies {
95
+ loadData(
96
+ symbol: string,
97
+ args: HeadlessPaneLoadArgs,
98
+ ctx: HeadlessPaneContext,
99
+ ): Promise<AnalystResearchData>;
100
+ }
101
+
102
+ const defaultDependencies: AnalystResearchHeadlessDependencies = {
103
+ loadData: (symbol, _args, ctx) => loadAnalystResearch(ctx.marketData, symbol),
104
+ };
105
+
106
+ export function createAnalystResearchHeadless(
107
+ dependencies: AnalystResearchHeadlessDependencies = defaultDependencies,
108
+ ): HeadlessPaneDefinition<"bundle"> {
109
+ return {
110
+ shape: "bundle",
111
+ argument: {
112
+ kind: "ticker",
113
+ placeholder: "ticker",
114
+ description: "Ticker whose analyst research should be loaded.",
115
+ },
116
+ options: [
117
+ {
118
+ key: "sort",
119
+ description: "Column used to sort analyst actions.",
120
+ type: "enum",
121
+ values: Object.keys(SORT_COLUMNS).map((value) => ({ value })),
122
+ defaultValue: "date",
123
+ },
124
+ {
125
+ key: "order",
126
+ description: "Sort direction.",
127
+ type: "enum",
128
+ values: [{ value: "desc" }, { value: "asc" }],
129
+ defaultValue: "desc",
130
+ },
131
+ {
132
+ key: "limit",
133
+ aliases: ["count", "rows"],
134
+ description: "Maximum analyst action rows.",
135
+ type: "integer",
136
+ defaultValue: 25,
137
+ minimum: 1,
138
+ maximum: 100,
139
+ },
140
+ ],
141
+ describe: (args) => `Analyst Research | ${args.symbols[0]}`,
142
+ async load(args, ctx) {
143
+ const symbol = args.symbols[0]!;
144
+ const data = await dependencies.loadData(symbol, args, ctx);
145
+ const currency = data.priceTarget?.currency ?? data.currency ?? "USD";
146
+ const ratings = sortRatingRows(data.ratings, {
147
+ columnId: SORT_COLUMNS[String(args.options.sort)] ?? "date",
148
+ direction: args.options.order === "asc" ? "asc" : "desc",
149
+ })
150
+ .slice(0, Number(args.options.limit))
151
+ .map((rating) => ({
152
+ ...rating,
153
+ target: rating.currentPriceTarget ?? rating.priorPriceTarget ?? null,
154
+ currency,
155
+ }));
156
+ const sections: HeadlessBundleSection[] = [
157
+ { title: "Summary", entries: overviewEntries(data) },
158
+ { title: "Recent analyst actions", columns: RATING_COLUMNS, rows: ratings },
159
+ ];
160
+ return {
161
+ sections,
162
+ metadata: {
163
+ symbol: data.symbol || symbol,
164
+ name: data.name ?? null,
165
+ currency,
166
+ },
167
+ };
168
+ },
169
+ };
170
+ }
171
+
172
+ export const analystResearchHeadless = createAnalystResearchHeadless();
@@ -0,0 +1,219 @@
1
+ import type { DataTableColumn } from "../../../components";
2
+ import type { AnalystRatingRecord, AnalystResearchData } from "../../../types/financials";
3
+ import { formatCurrency, formatNumber } from "../../../utils/format";
4
+ import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
5
+
6
+ function compactPeriod(period: string): string {
7
+ return period
8
+ .replace("current ", "")
9
+ .replace("previous ", "prev ")
10
+ .replace("next_", "next ")
11
+ .replace(/_/g, " ");
12
+ }
13
+
14
+ export function targetUpside(target: AnalystResearchData["priceTarget"]): number | undefined {
15
+ if (!target?.average || !target.current) return undefined;
16
+ return (target.average - target.current) / target.current;
17
+ }
18
+
19
+ export function latestRecommendation(data: AnalystResearchData | null) {
20
+ return data?.recommendations[0] ?? null;
21
+ }
22
+
23
+ export function recommendationTotal(data: AnalystResearchData | null): number {
24
+ const rec = latestRecommendation(data);
25
+ if (!rec) return 0;
26
+ return (rec.strongBuy ?? 0) + (rec.buy ?? 0) + (rec.hold ?? 0) + (rec.sell ?? 0) + (rec.strongSell ?? 0);
27
+ }
28
+
29
+ export function formatRatingLabel(value: number | undefined): string {
30
+ return value == null ? "-" : `${formatNumber(value, 1)}/10`;
31
+ }
32
+
33
+ export function formatPriceTarget(value: number | undefined, currency: string): string {
34
+ if (value == null) return "-";
35
+ return formatCurrency(value, currency)
36
+ .replace(/\.00\b/, "")
37
+ .replace(/(\.\d)0\b/, "$1");
38
+ }
39
+
40
+ const TARGET_SEPARATOR = " → ";
41
+
42
+ export interface RatingTargetColumnSizing {
43
+ targetPriorWidth: number;
44
+ targetCurrentWidth: number;
45
+ }
46
+
47
+ export function formatRatingTarget(
48
+ row: AnalystResearchData["ratings"][number],
49
+ currency: string,
50
+ sizing?: Partial<RatingTargetColumnSizing>,
51
+ ): string {
52
+ const current = row.currentPriceTarget;
53
+ const prior = row.priorPriceTarget;
54
+ if (current == null && prior == null) return "-";
55
+ if (current == null) return ` ${formatPriceTarget(prior, currency)}`;
56
+ if (prior == null) return ` ${formatPriceTarget(current, currency)}`;
57
+
58
+ const priorText = formatPriceTarget(prior, currency).padStart(sizing?.targetPriorWidth ?? 0);
59
+ const currentText = formatPriceTarget(current, currency).padEnd(sizing?.targetCurrentWidth ?? 0);
60
+ return ` ${priorText}${TARGET_SEPARATOR}${currentText}`;
61
+ }
62
+
63
+ export function ratingTargetDelta(row: AnalystResearchData["ratings"][number]): number | null {
64
+ if (row.currentPriceTarget == null || row.priorPriceTarget == null) return null;
65
+ return row.currentPriceTarget - row.priorPriceTarget;
66
+ }
67
+
68
+ export type RatingColumnId = "date" | "firm" | "action" | "current" | "target" | "prior";
69
+ export type RatingColumn = DataTableColumn & { id: RatingColumnId } & Partial<RatingTargetColumnSizing>;
70
+
71
+ export interface RatingSortPreference {
72
+ columnId: RatingColumnId;
73
+ direction: SortDirection;
74
+ }
75
+
76
+ export const DEFAULT_RATING_SORT: RatingSortPreference = {
77
+ columnId: "date",
78
+ direction: "desc",
79
+ };
80
+
81
+ const DEFAULT_RATING_SORT_DIRECTIONS: Record<RatingColumnId, SortDirection> = {
82
+ date: "desc",
83
+ firm: "asc",
84
+ action: "asc",
85
+ current: "asc",
86
+ target: "desc",
87
+ prior: "asc",
88
+ };
89
+
90
+ const BASE_RATING_COLUMNS: RatingColumn[] = [
91
+ { id: "date", label: "DATE", width: 10, align: "left" },
92
+ { id: "firm", label: "FIRM", width: 20, align: "left" },
93
+ { id: "action", label: "ACTION", width: 10, align: "left" },
94
+ { id: "current", label: "RATING", width: 13, align: "left" },
95
+ { id: "target", label: "TARGET", width: 13, align: "left" },
96
+ { id: "prior", label: "PRIOR", width: 13, align: "left" },
97
+ ];
98
+
99
+ export function buildRatingColumns(
100
+ rows: readonly AnalystRatingRecord[],
101
+ currency: string,
102
+ ): RatingColumn[] {
103
+ const targetSizing = rows.reduce<RatingTargetColumnSizing>(
104
+ (sizing, row) => ({
105
+ targetPriorWidth: Math.max(
106
+ sizing.targetPriorWidth,
107
+ row.priorPriceTarget == null ? 0 : formatPriceTarget(row.priorPriceTarget, currency).length,
108
+ ),
109
+ targetCurrentWidth: Math.max(
110
+ sizing.targetCurrentWidth,
111
+ row.currentPriceTarget == null ? 0 : formatPriceTarget(row.currentPriceTarget, currency).length,
112
+ ),
113
+ }),
114
+ { targetPriorWidth: 0, targetCurrentWidth: 0 },
115
+ );
116
+ const targetWidth = rows.reduce(
117
+ (width, row) => Math.max(width, formatRatingTarget(row, currency, targetSizing).length),
118
+ BASE_RATING_COLUMNS.find((column) => column.id === "target")?.width ?? 13,
119
+ );
120
+
121
+ return BASE_RATING_COLUMNS.map((column) => {
122
+ return column.id === "target" ? { ...column, ...targetSizing, width: targetWidth } : column;
123
+ });
124
+ }
125
+
126
+ function normalizedText(value: string | undefined): string | null {
127
+ const trimmed = value?.trim();
128
+ return trimmed ? trimmed.toLocaleLowerCase() : null;
129
+ }
130
+
131
+ function ratingTargetSortValue(row: AnalystRatingRecord): number | null {
132
+ return row.currentPriceTarget ?? row.priorPriceTarget ?? null;
133
+ }
134
+
135
+ function ratingSortValue(row: AnalystRatingRecord, columnId: RatingColumnId): string | number | null {
136
+ switch (columnId) {
137
+ case "date":
138
+ return normalizedText(row.date);
139
+ case "firm":
140
+ return normalizedText(row.firm);
141
+ case "action":
142
+ return normalizedText(row.action);
143
+ case "current":
144
+ return normalizedText(row.current);
145
+ case "target":
146
+ return ratingTargetSortValue(row);
147
+ case "prior":
148
+ return normalizedText(row.prior);
149
+ }
150
+ }
151
+
152
+ export function sortRatingRows<T extends AnalystRatingRecord>(
153
+ rows: readonly T[],
154
+ preference: RatingSortPreference,
155
+ ): T[] {
156
+ return rows
157
+ .map((row, index) => ({ row, index }))
158
+ .sort((left, right) => {
159
+ const primary = compareSortValues(
160
+ ratingSortValue(left.row, preference.columnId),
161
+ ratingSortValue(right.row, preference.columnId),
162
+ preference.direction,
163
+ );
164
+ if (primary !== 0) return primary;
165
+
166
+ const dateTieBreak = preference.columnId === "date"
167
+ ? 0
168
+ : compareSortValues(
169
+ ratingSortValue(left.row, "date"),
170
+ ratingSortValue(right.row, "date"),
171
+ "desc",
172
+ );
173
+ if (dateTieBreak !== 0) return dateTieBreak;
174
+
175
+ return left.index - right.index;
176
+ })
177
+ .map((entry) => entry.row);
178
+ }
179
+
180
+ export function nextRatingSortPreference(
181
+ current: RatingSortPreference,
182
+ columnId: string,
183
+ ): RatingSortPreference {
184
+ const typedColumnId = columnId as RatingColumnId;
185
+ if (current.columnId !== typedColumnId) {
186
+ return {
187
+ columnId: typedColumnId,
188
+ direction: DEFAULT_RATING_SORT_DIRECTIONS[typedColumnId] ?? "asc",
189
+ };
190
+ }
191
+ return {
192
+ columnId: typedColumnId,
193
+ direction: current.direction === "asc" ? "desc" : "asc",
194
+ };
195
+ }
196
+
197
+ export function buildAnalystSummaryLines(data: AnalystResearchData | null): string[] {
198
+ if (!data) return [];
199
+
200
+ const target = data.priceTarget;
201
+ const currency = target?.currency ?? data.currency ?? "USD";
202
+ const price = (value: number | undefined) => value != null ? formatCurrency(value, currency) : "-";
203
+ const rec = latestRecommendation(data);
204
+ const total = recommendationTotal(data);
205
+ const lines: string[] = [];
206
+
207
+ if (target) {
208
+ lines.push(`low ${price(target.low)} med ${price(target.median)} high ${price(target.high)}`);
209
+ }
210
+ if (data.recommendationRating != null || rec || total > 0) {
211
+ lines.push([
212
+ data.recommendationRating != null ? `rating ${formatRatingLabel(data.recommendationRating)}` : null,
213
+ rec ? `SB ${rec.strongBuy ?? 0} B ${rec.buy ?? 0} H ${rec.hold ?? 0} S ${(rec.sell ?? 0) + (rec.strongSell ?? 0)}` : null,
214
+ total > 0 ? `${total} analysts${rec?.period ? ` (${compactPeriod(rec.period)})` : ""}` : null,
215
+ ].filter(Boolean).join(" "));
216
+ }
217
+
218
+ return lines;
219
+ }