gloomberb 0.12.0 → 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 (219) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.test.ts +34 -0
  5. package/src/api-client/index.ts +10 -2
  6. package/src/api-client/request.ts +4 -1
  7. package/src/api-client/research-activity.ts +61 -0
  8. package/src/api-client/types.ts +2 -1
  9. package/src/cli/commands/plugins.ts +4 -3
  10. package/src/cli/desktop-pane-shot.ts +79 -10
  11. package/src/cli/pane-functions/capabilities.ts +76 -23
  12. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  13. package/src/cli/pane-functions/cloud-session.ts +38 -0
  14. package/src/cli/pane-functions/dom.test.ts +41 -0
  15. package/src/cli/pane-functions/dom.ts +199 -0
  16. package/src/cli/pane-functions/index.test.ts +13 -2
  17. package/src/cli/pane-functions/index.ts +9 -5
  18. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  19. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  20. package/src/cli/pane-functions/report.test.ts +29 -1
  21. package/src/cli/pane-functions/report.ts +68 -7
  22. package/src/cli/pane-functions/screenshot.ts +195 -42
  23. package/src/cli/restore-plugins.ts +10 -3
  24. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  25. package/src/components/chart/composite/composite-chart.tsx +87 -21
  26. package/src/components/chart/composite/rasterizer.ts +33 -0
  27. package/src/components/chart/composite/renderers.test.ts +51 -4
  28. package/src/components/chart/composite/scene.test.ts +128 -2
  29. package/src/components/chart/composite/scene.ts +92 -16
  30. package/src/components/chart/composite/text-renderer.ts +13 -0
  31. package/src/components/chart/composite/time-scale.ts +30 -10
  32. package/src/components/chart/composite/types.ts +26 -0
  33. package/src/components/command-bar/assist/runtime.ts +38 -30
  34. package/src/components/command-bar/surface/assist.test.tsx +63 -9
  35. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  36. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  37. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  38. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  39. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  40. package/src/data/config/store/index.test.ts +23 -0
  41. package/src/data/config/store/node.ts +7 -1
  42. package/src/data/config/store/normalize.ts +4 -0
  43. package/src/news/types.ts +1 -0
  44. package/src/plugins/builtin/cds/client.test.ts +20 -0
  45. package/src/plugins/builtin/cds/client.ts +13 -3
  46. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  47. package/src/plugins/builtin/cds/headless.ts +113 -0
  48. package/src/plugins/builtin/cds/index.tsx +4 -0
  49. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  50. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  51. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  52. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  53. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  54. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  55. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  56. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  57. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  58. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  59. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  60. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  61. package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
  62. package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
  63. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  64. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  65. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  66. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  67. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  68. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  69. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  70. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  71. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  72. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  73. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  74. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  75. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  76. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  77. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  78. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  79. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  80. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  81. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  82. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  83. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  84. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  85. package/src/plugins/builtin/earnings/headless.ts +162 -0
  86. package/src/plugins/builtin/earnings/index.tsx +2 -0
  87. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  88. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  89. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  90. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  91. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  93. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
  94. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  95. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  96. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  97. package/src/plugins/builtin/holders/client.ts +29 -0
  98. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  99. package/src/plugins/builtin/holders/headless.ts +162 -0
  100. package/src/plugins/builtin/holders/index.tsx +14 -8
  101. package/src/plugins/builtin/holders/pane.tsx +7 -1
  102. package/src/plugins/builtin/insider/client.ts +50 -0
  103. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  104. package/src/plugins/builtin/insider/headless.ts +117 -0
  105. package/src/plugins/builtin/insider/index.tsx +21 -50
  106. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  107. package/src/plugins/builtin/insider/model.ts +72 -0
  108. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  109. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  110. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  111. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  112. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  113. package/src/plugins/builtin/market-movers/client.ts +76 -0
  114. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  115. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  116. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  117. package/src/plugins/builtin/news/headless.test.ts +138 -0
  118. package/src/plugins/builtin/news/headless.ts +190 -0
  119. package/src/plugins/builtin/news/index.tsx +13 -9
  120. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  121. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  122. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  123. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  124. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  125. package/src/plugins/builtin/research/client.ts +12 -0
  126. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  127. package/src/plugins/builtin/research/event-model.ts +245 -0
  128. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  129. package/src/plugins/builtin/research/events-headless.ts +87 -0
  130. package/src/plugins/builtin/research/headless.test.ts +93 -0
  131. package/src/plugins/builtin/research/headless.ts +161 -0
  132. package/src/plugins/builtin/research/index.tsx +32 -18
  133. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  134. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  135. package/src/plugins/builtin/scanner/headless.ts +125 -0
  136. package/src/plugins/builtin/scanner/index.ts +4 -0
  137. package/src/plugins/builtin/sec/client.ts +16 -0
  138. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  139. package/src/plugins/builtin/sec/headless.ts +69 -0
  140. package/src/plugins/builtin/sec/index.tsx +20 -75
  141. package/src/plugins/builtin/sec/model.ts +89 -0
  142. package/src/plugins/builtin/sectors/client.ts +54 -0
  143. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  144. package/src/plugins/builtin/sectors/headless.ts +80 -0
  145. package/src/plugins/builtin/sectors/index.tsx +6 -51
  146. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  147. package/src/plugins/builtin/short-interest/client.ts +5 -2
  148. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  149. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  150. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  151. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  152. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  153. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  154. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  155. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  156. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  157. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  158. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  159. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  160. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  161. package/src/plugins/builtin/volatility/client.ts +16 -3
  162. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  163. package/src/plugins/builtin/volatility/headless.ts +75 -0
  164. package/src/plugins/builtin/volatility/index.tsx +4 -0
  165. package/src/plugins/builtin/world-indices/client.ts +43 -0
  166. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  167. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  168. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  169. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  170. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  171. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  172. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  173. package/src/plugins/bundle.test.ts +58 -1
  174. package/src/plugins/bundle.ts +4 -2
  175. package/src/plugins/catalog-backend.test.ts +26 -0
  176. package/src/plugins/catalog-backend.ts +9 -2
  177. package/src/plugins/catalog-browser.test.ts +0 -13
  178. package/src/plugins/catalog-browser.ts +4 -0
  179. package/src/plugins/catalog-manifest.test.ts +15 -0
  180. package/src/plugins/catalog-manifest.ts +12 -5
  181. package/src/plugins/catalog-ui.ts +0 -6
  182. package/src/plugins/host-contract.ts +9 -0
  183. package/src/plugins/host-modules.ts +12 -0
  184. package/src/plugins/loader.ts +63 -15
  185. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  186. package/src/plugins/prediction-markets/headless.ts +255 -0
  187. package/src/plugins/prediction-markets/index.tsx +2 -0
  188. package/src/plugins/seed.ts +3 -0
  189. package/src/public/broker.ts +6 -0
  190. package/src/remote/schema.test.ts +65 -0
  191. package/src/remote/schema.ts +322 -39
  192. package/src/remote/types.ts +49 -2
  193. package/src/renderers/browser/app-services.ts +3 -0
  194. package/src/renderers/browser/config-host.ts +24 -8
  195. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  196. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  197. package/src/renderers/browser/main.tsx +3 -1
  198. package/src/renderers/browser/research-entry.test.ts +8 -0
  199. package/src/renderers/browser/research-entry.ts +7 -0
  200. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  201. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  202. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  203. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  204. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  205. package/src/sources/gloomberb-cloud/news.ts +1 -0
  206. package/src/sync/core-contributors.test.ts +111 -0
  207. package/src/sync/core-contributors.ts +155 -1
  208. package/src/time-series/resolve.test.ts +118 -1
  209. package/src/time-series/resolve.ts +14 -16
  210. package/src/types/config.ts +2 -0
  211. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  212. package/src/plugins/broker-sync/native-loader.ts +0 -10
  213. package/src/plugins/broker-sync/normalize.ts +0 -308
  214. package/src/plugins/broker-sync/public.ts +0 -122
  215. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  216. package/src/plugins/broker-sync/robinhood.ts +0 -114
  217. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  218. package/src/plugins/broker-sync/simplefin.ts +0 -70
  219. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
@@ -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
+ }
@@ -4,43 +4,36 @@ import {
4
4
  DataTableView,
5
5
  usePaneFooter,
6
6
  type DataTableCell,
7
- type DataTableColumn,
8
7
  type DataTableKeyEvent,
9
8
  } from "../../../components";
10
- import type { AnalystRatingRecord, AnalystResearchData } from "../../../types/financials";
9
+ import type { AnalystResearchData } from "../../../types/financials";
11
10
  import { blendHex, colors, priceColor } from "../../../theme/colors";
12
- import { formatCurrency, formatNumber, formatPercent } from "../../../utils/format";
13
- import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
11
+ import { formatCurrency, formatPercent } from "../../../utils/format";
14
12
  import { useAssetData } from "../../runtime";
15
13
  import { handleRefreshKey, loadingErrorFooterInfo } from "../shared/table-pane";
16
14
  import { useBoundTicker as useSymbolBinding, useTickerRequest } from "../shared/ticker-request";
17
-
18
- function compactPeriod(period: string): string {
19
- return period
20
- .replace("current ", "")
21
- .replace("previous ", "prev ")
22
- .replace("next_", "next ")
23
- .replace(/_/g, " ");
24
- }
25
-
26
- function targetUpside(target: AnalystResearchData["priceTarget"]): number | undefined {
27
- if (!target?.average || !target.current) return undefined;
28
- return (target.average - target.current) / target.current;
29
- }
30
-
31
- function latestRecommendation(data: AnalystResearchData | null) {
32
- return data?.recommendations[0] ?? null;
33
- }
34
-
35
- function recommendationTotal(data: AnalystResearchData | null): number {
36
- const rec = latestRecommendation(data);
37
- if (!rec) return 0;
38
- return (rec.strongBuy ?? 0) + (rec.buy ?? 0) + (rec.hold ?? 0) + (rec.sell ?? 0) + (rec.strongSell ?? 0);
39
- }
40
-
41
- function formatRatingLabel(value: number | undefined): string {
42
- return value == null ? "-" : `${formatNumber(value, 1)}/10`;
43
- }
15
+ import { loadAnalystResearch } from "./client";
16
+ import {
17
+ DEFAULT_RATING_SORT,
18
+ buildAnalystSummaryLines,
19
+ buildRatingColumns,
20
+ formatRatingTarget,
21
+ nextRatingSortPreference,
22
+ ratingTargetDelta,
23
+ sortRatingRows,
24
+ targetUpside,
25
+ type RatingColumn,
26
+ type RatingSortPreference,
27
+ } from "./analyst-model";
28
+
29
+ export {
30
+ buildAnalystSummaryLines,
31
+ buildRatingColumns,
32
+ formatRatingTarget,
33
+ nextRatingSortPreference,
34
+ sortRatingRows,
35
+ type RatingSortPreference,
36
+ } from "./analyst-model";
44
37
 
45
38
  function ratingActionColor(action: string | undefined): string {
46
39
  const normalized = action?.toLowerCase() ?? "";
@@ -49,41 +42,6 @@ function ratingActionColor(action: string | undefined): string {
49
42
  return colors.textDim;
50
43
  }
51
44
 
52
- function formatPriceTarget(value: number | undefined, currency: string): string {
53
- if (value == null) return "-";
54
- return formatCurrency(value, currency)
55
- .replace(/\.00\b/, "")
56
- .replace(/(\.\d)0\b/, "$1");
57
- }
58
-
59
- const TARGET_SEPARATOR = " → ";
60
-
61
- interface RatingTargetColumnSizing {
62
- targetPriorWidth: number;
63
- targetCurrentWidth: number;
64
- }
65
-
66
- export function formatRatingTarget(
67
- row: AnalystResearchData["ratings"][number],
68
- currency: string,
69
- sizing?: Partial<RatingTargetColumnSizing>,
70
- ): string {
71
- const current = row.currentPriceTarget;
72
- const prior = row.priorPriceTarget;
73
- if (current == null && prior == null) return "-";
74
- if (current == null) return ` ${formatPriceTarget(prior, currency)}`;
75
- if (prior == null) return ` ${formatPriceTarget(current, currency)}`;
76
-
77
- const priorText = formatPriceTarget(prior, currency).padStart(sizing?.targetPriorWidth ?? 0);
78
- const currentText = formatPriceTarget(current, currency).padEnd(sizing?.targetCurrentWidth ?? 0);
79
- return ` ${priorText}${TARGET_SEPARATOR}${currentText}`;
80
- }
81
-
82
- function ratingTargetDelta(row: AnalystResearchData["ratings"][number]): number | null {
83
- if (row.currentPriceTarget == null || row.priorPriceTarget == null) return null;
84
- return row.currentPriceTarget - row.priorPriceTarget;
85
- }
86
-
87
45
  function ratingTargetBackground(delta: number | null): string | undefined {
88
46
  if (delta == null || delta === 0) return undefined;
89
47
  return blendHex(colors.bg, delta > 0 ? colors.positive : colors.negative, 0.42);
@@ -93,30 +51,6 @@ function ratingTargetBackground(delta: number | null): string | undefined {
93
51
  * Price targets and the recommendation mix are pane content, not status, so the
94
52
  * summary block owns them and the footer stays empty unless something changed.
95
53
  */
96
- export function buildAnalystSummaryLines(data: AnalystResearchData | null): string[] {
97
- if (!data) return [];
98
-
99
- const target = data.priceTarget;
100
- const currency = target?.currency ?? data.currency ?? "USD";
101
- const price = (value: number | undefined) => value != null ? formatCurrency(value, currency) : "-";
102
- const rec = latestRecommendation(data);
103
- const total = recommendationTotal(data);
104
- const lines: string[] = [];
105
-
106
- if (target) {
107
- lines.push(`low ${price(target.low)} med ${price(target.median)} high ${price(target.high)}`);
108
- }
109
- if (data.recommendationRating != null || rec || total > 0) {
110
- lines.push([
111
- data.recommendationRating != null ? `rating ${formatRatingLabel(data.recommendationRating)}` : null,
112
- rec ? `SB ${rec.strongBuy ?? 0} B ${rec.buy ?? 0} H ${rec.hold ?? 0} S ${(rec.sell ?? 0) + (rec.strongSell ?? 0)}` : null,
113
- total > 0 ? `${total} analysts${rec?.period ? ` (${compactPeriod(rec.period)})` : ""}` : null,
114
- ].filter(Boolean).join(" "));
115
- }
116
-
117
- return lines;
118
- }
119
-
120
54
  function AnalystSummary({ data }: { data: AnalystResearchData | null }) {
121
55
  const target = data?.priceTarget;
122
56
  const upside = targetUpside(target);
@@ -147,142 +81,18 @@ function AnalystSummary({ data }: { data: AnalystResearchData | null }) {
147
81
  );
148
82
  }
149
83
 
150
- type RatingColumnId = "date" | "firm" | "action" | "current" | "target" | "prior";
151
- type RatingColumn = DataTableColumn & { id: RatingColumnId } & Partial<RatingTargetColumnSizing>;
152
-
153
- export interface RatingSortPreference {
154
- columnId: RatingColumnId;
155
- direction: SortDirection;
156
- }
157
-
158
- const DEFAULT_RATING_SORT: RatingSortPreference = {
159
- columnId: "date",
160
- direction: "desc",
161
- };
162
-
163
- const DEFAULT_RATING_SORT_DIRECTIONS: Record<RatingColumnId, SortDirection> = {
164
- date: "desc",
165
- firm: "asc",
166
- action: "asc",
167
- current: "asc",
168
- target: "desc",
169
- prior: "asc",
170
- };
171
-
172
- const BASE_RATING_COLUMNS: RatingColumn[] = [
173
- { id: "date", label: "DATE", width: 10, align: "left" },
174
- { id: "firm", label: "FIRM", width: 20, align: "left" },
175
- { id: "action", label: "ACTION", width: 10, align: "left" },
176
- { id: "current", label: "RATING", width: 13, align: "left" },
177
- { id: "target", label: "TARGET", width: 13, align: "left" },
178
- { id: "prior", label: "PRIOR", width: 13, align: "left" },
179
- ];
180
-
181
- export function buildRatingColumns(
182
- rows: readonly AnalystRatingRecord[],
183
- currency: string,
184
- ): RatingColumn[] {
185
- const targetSizing = rows.reduce<RatingTargetColumnSizing>(
186
- (sizing, row) => ({
187
- targetPriorWidth: Math.max(
188
- sizing.targetPriorWidth,
189
- row.priorPriceTarget == null ? 0 : formatPriceTarget(row.priorPriceTarget, currency).length,
190
- ),
191
- targetCurrentWidth: Math.max(
192
- sizing.targetCurrentWidth,
193
- row.currentPriceTarget == null ? 0 : formatPriceTarget(row.currentPriceTarget, currency).length,
194
- ),
195
- }),
196
- { targetPriorWidth: 0, targetCurrentWidth: 0 },
197
- );
198
- const targetWidth = rows.reduce(
199
- (width, row) => Math.max(width, formatRatingTarget(row, currency, targetSizing).length),
200
- BASE_RATING_COLUMNS.find((column) => column.id === "target")?.width ?? 13,
201
- );
202
-
203
- return BASE_RATING_COLUMNS.map((column) => {
204
- return column.id === "target" ? { ...column, ...targetSizing, width: targetWidth } : column;
205
- });
206
- }
207
-
208
- function normalizedText(value: string | undefined): string | null {
209
- const trimmed = value?.trim();
210
- return trimmed ? trimmed.toLocaleLowerCase() : null;
211
- }
212
-
213
- function ratingTargetSortValue(row: AnalystRatingRecord): number | null {
214
- return row.currentPriceTarget ?? row.priorPriceTarget ?? null;
215
- }
216
-
217
- function ratingSortValue(row: AnalystRatingRecord, columnId: RatingColumnId): string | number | null {
218
- switch (columnId) {
219
- case "date":
220
- return normalizedText(row.date);
221
- case "firm":
222
- return normalizedText(row.firm);
223
- case "action":
224
- return normalizedText(row.action);
225
- case "current":
226
- return normalizedText(row.current);
227
- case "target":
228
- return ratingTargetSortValue(row);
229
- case "prior":
230
- return normalizedText(row.prior);
231
- }
232
- }
233
-
234
- export function sortRatingRows<T extends AnalystRatingRecord>(
235
- rows: readonly T[],
236
- preference: RatingSortPreference,
237
- ): T[] {
238
- return rows
239
- .map((row, index) => ({ row, index }))
240
- .sort((left, right) => {
241
- const primary = compareSortValues(
242
- ratingSortValue(left.row, preference.columnId),
243
- ratingSortValue(right.row, preference.columnId),
244
- preference.direction,
245
- );
246
- if (primary !== 0) return primary;
247
-
248
- const dateTieBreak = preference.columnId === "date"
249
- ? 0
250
- : compareSortValues(
251
- ratingSortValue(left.row, "date"),
252
- ratingSortValue(right.row, "date"),
253
- "desc",
254
- );
255
- if (dateTieBreak !== 0) return dateTieBreak;
256
-
257
- return left.index - right.index;
258
- })
259
- .map((entry) => entry.row);
260
- }
261
-
262
- export function nextRatingSortPreference(
263
- current: RatingSortPreference,
264
- columnId: string,
265
- ): RatingSortPreference {
266
- const typedColumnId = columnId as RatingColumnId;
267
- if (current.columnId !== typedColumnId) {
268
- return {
269
- columnId: typedColumnId,
270
- direction: DEFAULT_RATING_SORT_DIRECTIONS[typedColumnId] ?? "asc",
271
- };
272
- }
273
- return {
274
- columnId: typedColumnId,
275
- direction: current.direction === "asc" ? "desc" : "asc",
276
- };
277
- }
278
-
279
84
  export function AnalystResearchView({ focused, width, height }: { focused: boolean; width: number; height: number }) {
280
85
  const dataProvider = useAssetData();
281
86
  const { symbol, exchange } = useSymbolBinding();
282
87
  const [sortPreference, setSortPreference] = useState<RatingSortPreference>(DEFAULT_RATING_SORT);
283
88
  const loader = useCallback((nextSymbol: string, nextExchange: string, forceRefresh: boolean) => {
284
- if (!dataProvider?.getAnalystResearch) throw new Error("Analyst data unavailable");
285
- return dataProvider.getAnalystResearch(nextSymbol, nextExchange, forceRefresh ? { cacheMode: "refresh" } : undefined);
89
+ if (!dataProvider) throw new Error("Analyst data unavailable");
90
+ return loadAnalystResearch(
91
+ dataProvider,
92
+ nextSymbol,
93
+ nextExchange,
94
+ forceRefresh ? { cacheMode: "refresh" } : undefined,
95
+ );
286
96
  }, [dataProvider]);
287
97
  const { data, loading, error, reload } = useTickerRequest<AnalystResearchData>(loader, symbol, exchange);
288
98
  const rows = useMemo(() => sortRatingRows(data?.ratings ?? [], sortPreference), [data?.ratings, sortPreference]);
@@ -0,0 +1,12 @@
1
+ import type { DataProvider, MarketDataRequestContext } from "../../../types/data-provider";
2
+ import type { AnalystResearchData } from "../../../types/financials";
3
+
4
+ export async function loadAnalystResearch(
5
+ provider: DataProvider,
6
+ symbol: string,
7
+ exchange = "",
8
+ context?: MarketDataRequestContext,
9
+ ): Promise<AnalystResearchData> {
10
+ if (!provider.getAnalystResearch) throw new Error("Analyst data unavailable");
11
+ return provider.getAnalystResearch(symbol, exchange, context);
12
+ }