gloomberb 0.9.2 → 0.9.4

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 (233) hide show
  1. package/README.md +24 -2
  2. package/bin/gloomberb +1 -1
  3. package/package.json +1 -1
  4. package/src/app/runtime/desktop-menu.ts +5 -1
  5. package/src/app/runtime/ticker-refresh.ts +4 -1
  6. package/src/app.tsx +20 -8
  7. package/src/brokers/account-cache.ts +1 -1
  8. package/src/brokers/sync-broker-instance.test.ts +107 -0
  9. package/src/brokers/sync-broker-instance.ts +22 -10
  10. package/src/capabilities/registry.ts +1 -0
  11. package/src/capabilities/types.ts +23 -0
  12. package/src/cli/commands/api.ts +119 -0
  13. package/src/cli/commands/automation.ts +173 -0
  14. package/src/cli/commands/command-utils.ts +53 -0
  15. package/src/cli/commands/market.ts +527 -0
  16. package/src/cli/commands/overview.ts +222 -0
  17. package/src/cli/commands/search.ts +39 -0
  18. package/src/cli/commands/system.ts +553 -0
  19. package/src/cli/commands/ticker.ts +104 -12
  20. package/src/cli/context.ts +35 -2
  21. package/src/cli/desktop-pane-shot.ts +2 -1
  22. package/src/cli/entry.ts +51 -0
  23. package/src/cli/errors.ts +56 -5
  24. package/src/cli/index.ts +73 -11
  25. package/src/cli/options.test.ts +38 -0
  26. package/src/cli/options.ts +95 -0
  27. package/src/cli/pane-functions/index.ts +10 -1
  28. package/src/cli/pane-functions/screenshot.ts +13 -1
  29. package/src/cli/registry.test.ts +40 -0
  30. package/src/cli/registry.ts +46 -5
  31. package/src/cli/result.test.ts +82 -0
  32. package/src/cli/result.ts +149 -0
  33. package/src/cli/types.ts +11 -0
  34. package/src/cli.test.ts +11 -11
  35. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  36. package/src/components/chart/bar-chart-renderer.ts +226 -27
  37. package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
  38. package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
  39. package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
  40. package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
  41. package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
  42. package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
  43. package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
  44. package/src/components/chart/core/pane-settings.test.ts +27 -0
  45. package/src/components/chart/core/pane-settings.ts +60 -1
  46. package/src/components/chart/core/pointer.ts +34 -6
  47. package/src/components/chart/price-axis-labels.tsx +3 -1
  48. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  49. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  50. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  51. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  52. package/src/components/chart/static/chart/surface.tsx +167 -24
  53. package/src/components/chart/stock-chart/index.test.ts +25 -0
  54. package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
  55. package/src/components/chart/stock-chart/settings.ts +7 -3
  56. package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
  57. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  58. package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
  59. package/src/components/command-bar/surface/pane.test.tsx +76 -0
  60. package/src/components/data-table/view.tsx +13 -2
  61. package/src/components/feed-data-table/stack-view.tsx +9 -1
  62. package/src/components/index.ts +1 -1
  63. package/src/components/input-search-bar.test.tsx +103 -0
  64. package/src/components/input-search-bar.tsx +16 -0
  65. package/src/components/layout/detached-pane-shell.tsx +6 -3
  66. package/src/components/layout/floating-pane.tsx +3 -2
  67. package/src/components/layout/pane/content.tsx +20 -8
  68. package/src/components/layout/pane/footer/registration.tsx +6 -2
  69. package/src/components/layout/pane/frame.tsx +4 -4
  70. package/src/components/layout/pane/header.tsx +40 -4
  71. package/src/components/layout/pane/index.tsx +3 -1
  72. package/src/components/layout/pane/sizing.test.ts +43 -0
  73. package/src/components/layout/pane/sizing.ts +13 -4
  74. package/src/components/layout/shell/active-drag.ts +5 -1
  75. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  76. package/src/components/layout/shell/drag/runtime.ts +28 -3
  77. package/src/components/layout/shell/fullscreen.ts +10 -0
  78. package/src/components/layout/shell/index.test.tsx +487 -4
  79. package/src/components/layout/shell/index.tsx +165 -4
  80. package/src/components/layout/shell/layout-state.test.ts +33 -0
  81. package/src/components/layout/shell/layout-state.ts +4 -9
  82. package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
  83. package/src/components/layout/shell/pane/layers.tsx +34 -16
  84. package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
  85. package/src/components/layout/shell/shortcuts.ts +4 -0
  86. package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
  87. package/src/components/layout/shell/visible-layout.ts +17 -0
  88. package/src/components/layout/shell/window-mode/index.ts +11 -3
  89. package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
  90. package/src/components/layout/status-bar.test.tsx +111 -1
  91. package/src/components/layout/status-bar.tsx +37 -7
  92. package/src/components/layout/transient-layout.tsx +57 -0
  93. package/src/components/layout/window-edit/mode.test.ts +76 -5
  94. package/src/components/layout/window-edit/mode.ts +47 -2
  95. package/src/components/layout/window-edit/presentation.ts +2 -1
  96. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  97. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  98. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  99. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  100. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  101. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  102. package/src/components/price-performance.tsx +9 -1
  103. package/src/components/table-view-shared.tsx +12 -4
  104. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  105. package/src/components/ui/data-table/types.ts +4 -0
  106. package/src/components/ui/fields.tsx +10 -3
  107. package/src/components/ui/page-stack-view.tsx +6 -2
  108. package/src/components/ui/table-layout.test.ts +25 -0
  109. package/src/components/ui/table-layout.ts +22 -6
  110. package/src/components/ui/tabs.tsx +26 -10
  111. package/src/components/ui/ui.test.tsx +87 -0
  112. package/src/core/app-services.ts +7 -4
  113. package/src/core/state/app/layout-reducer.ts +2 -2
  114. package/src/data/config/store/index.ts +1 -2
  115. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  116. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  117. package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
  118. package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
  119. package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
  120. package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
  121. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  122. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  123. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  124. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  125. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  126. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  127. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  128. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  129. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  130. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  131. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  132. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  133. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  134. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  135. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  136. package/src/plugins/builtin/news/wire/read-state.ts +9 -32
  137. package/src/plugins/builtin/options/view.test.tsx +141 -4
  138. package/src/plugins/builtin/options/view.tsx +76 -15
  139. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  140. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  141. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
  142. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
  143. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  144. package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
  146. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  147. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  148. package/src/plugins/builtin/shared/read-state.ts +43 -0
  149. package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
  150. package/src/plugins/builtin/substack/article-detail.tsx +39 -15
  151. package/src/plugins/builtin/substack/article-stack.tsx +6 -2
  152. package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
  153. package/src/plugins/builtin/substack/pane.tsx +8 -3
  154. package/src/plugins/builtin/substack/read-state.ts +55 -0
  155. package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
  156. package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
  157. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  158. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  161. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  162. package/src/plugins/catalog-ui.ts +2 -0
  163. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  164. package/src/plugins/ibkr/config.test.ts +31 -2
  165. package/src/plugins/ibkr/config.ts +9 -2
  166. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  167. package/src/plugins/ibkr/flex/index.ts +30 -0
  168. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  169. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  170. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  171. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  172. package/src/plugins/pane-manager/docking.ts +9 -2
  173. package/src/plugins/pane-manager/gridlock.ts +13 -5
  174. package/src/plugins/pane-manager/layout-state.ts +25 -0
  175. package/src/plugins/pane-manager/queries.ts +8 -2
  176. package/src/plugins/pane-manager.test.ts +33 -0
  177. package/src/plugins/pane-manager.ts +6 -1
  178. package/src/plugins/prediction-markets/chart.tsx +10 -4
  179. package/src/plugins/prediction-markets/controller/index.ts +2 -0
  180. package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
  181. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  182. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  183. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  184. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  185. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  186. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  187. package/src/plugins/prediction-markets/pane.test.tsx +75 -0
  188. package/src/plugins/prediction-markets/pane.tsx +71 -23
  189. package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
  190. package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
  191. package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
  192. package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
  193. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  194. package/src/renderers/electrobun/bun/index.ts +13 -0
  195. package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
  196. package/src/renderers/electrobun/bun/window/focus.ts +5 -0
  197. package/src/renderers/electrobun/view/app-services.ts +6 -4
  198. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  199. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  200. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  201. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  202. package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
  203. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  204. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  205. package/src/renderers/electrobun/view/host/text.test.ts +22 -0
  206. package/src/renderers/electrobun/view/host/text.tsx +24 -20
  207. package/src/renderers/electrobun/view/styles.css +11 -0
  208. package/src/renderers/electrobun/view/ui-host.tsx +1 -1
  209. package/src/renderers/opentui/host.tsx +17 -1
  210. package/src/renderers/opentui/start.tsx +13 -5
  211. package/src/renderers/opentui/ui-host.tsx +57 -25
  212. package/src/sources/provider-router/batches.ts +16 -2
  213. package/src/sources/provider-router/financial-routes.ts +5 -0
  214. package/src/sources/provider-router/financials.ts +11 -23
  215. package/src/sources/provider-router/index.test.ts +189 -0
  216. package/src/sources/provider-router/primary.ts +3 -2
  217. package/src/sources/sec-edgar.test.ts +83 -0
  218. package/src/sources/sec-edgar.ts +231 -0
  219. package/src/sources/yahoo-finance.test.ts +51 -0
  220. package/src/sources/yahoo-finance.ts +49 -1
  221. package/src/state/app/context/index.test.tsx +5 -8
  222. package/src/state/app/input-capture.ts +12 -0
  223. package/src/types/broker.ts +1 -0
  224. package/src/types/config.ts +10 -0
  225. package/src/types/plugin.ts +28 -0
  226. package/src/types/trading.ts +2 -0
  227. package/src/ui/host.tsx +16 -0
  228. package/src/ui/index.tsx +2 -0
  229. package/src/utils/cli-output.ts +6 -0
  230. package/src/utils/financial-statements.ts +23 -0
  231. package/src/utils/search-focus-navigation.ts +24 -0
  232. package/src/version.ts +1 -1
  233. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -1,5 +1,6 @@
1
1
  import type { NativeChartBitmap } from "./native/chart-rasterizer";
2
2
  import { fillRect, parseHex } from "./native/raster/primitives";
3
+ import { formatCompact } from "../../utils/format";
3
4
 
4
5
  interface BarChartPoint {
5
6
  category: string;
@@ -17,6 +18,8 @@ export interface BarChartColors {
17
18
  bgColor: string;
18
19
  gridColor: string;
19
20
  axisColor: string;
21
+ negativeColor?: string;
22
+ hoverColor?: string;
20
23
  }
21
24
 
22
25
  export interface BarChartSceneOptions {
@@ -25,8 +28,9 @@ export interface BarChartSceneOptions {
25
28
  colors: BarChartColors;
26
29
  }
27
30
 
28
- interface BarChartBar {
31
+ export interface BarChartBar {
29
32
  seriesId: string;
33
+ seriesLabel: string;
30
34
  category: string;
31
35
  value: number;
32
36
  color: string;
@@ -47,8 +51,35 @@ export interface BarChartScene {
47
51
  colors: BarChartColors;
48
52
  }
49
53
 
54
+ export interface BarChartHover {
55
+ seriesId: string;
56
+ seriesLabel: string;
57
+ category: string;
58
+ value: number;
59
+ color: string;
60
+ x: number;
61
+ width: number;
62
+ row: number;
63
+ }
64
+
65
+ export interface NativeBarChartPixelBounds {
66
+ left: number;
67
+ rightExclusive: number;
68
+ }
69
+
50
70
  const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
51
71
 
72
+ function categorySlotBounds(width: number, categoryCount: number, index: number): { left: number; width: number } {
73
+ const left = Math.floor((index * width) / categoryCount);
74
+ const right = index === categoryCount - 1
75
+ ? width
76
+ : Math.floor(((index + 1) * width) / categoryCount);
77
+ return {
78
+ left,
79
+ width: Math.max(1, right - left),
80
+ };
81
+ }
82
+
52
83
  function collectCategories(series: BarChartSeries[]): string[] {
53
84
  const categories: string[] = [];
54
85
  const seen = new Set<string>();
@@ -64,14 +95,19 @@ function collectCategories(series: BarChartSeries[]): string[] {
64
95
 
65
96
  function normalizeRange(values: number[]): { min: number; max: number } {
66
97
  if (values.length === 0) return { min: 0, max: 1 };
67
- const rawMin = Math.min(0, ...values);
68
- const rawMax = Math.max(0, ...values);
69
- if (rawMin === rawMax) {
70
- const delta = Math.max(Math.abs(rawMin) * 0.1, 1);
71
- return { min: rawMin - delta, max: rawMax + delta };
98
+ const dataMin = Math.min(...values);
99
+ const dataMax = Math.max(...values);
100
+ if (dataMin === dataMax) {
101
+ if (dataMin === 0) return { min: -1, max: 1 };
102
+ const delta = Math.max(Math.abs(dataMin) * 0.1, 1);
103
+ return dataMin > 0
104
+ ? { min: 0, max: dataMax + delta }
105
+ : { min: dataMin - delta, max: 0 };
72
106
  }
73
- const padding = (rawMax - rawMin) * 0.08;
74
- return { min: rawMin - padding, max: rawMax + padding };
107
+ const padding = (dataMax - dataMin) * 0.08;
108
+ if (dataMin >= 0) return { min: 0, max: dataMax + padding };
109
+ if (dataMax <= 0) return { min: dataMin - padding, max: 0 };
110
+ return { min: dataMin - padding, max: dataMax + padding };
75
111
  }
76
112
 
77
113
  function valueToRow(value: number, min: number, max: number, height: number): number {
@@ -79,6 +115,12 @@ function valueToRow(value: number, min: number, max: number, height: number): nu
79
115
  return clamp(Math.round((1 - t) * (height - 1)), 0, height - 1);
80
116
  }
81
117
 
118
+ function rowToValue(row: number, scene: Pick<BarChartScene, "height" | "min" | "max">): number {
119
+ if (scene.height <= 1) return scene.max;
120
+ const t = 1 - row / Math.max(scene.height - 1, 1);
121
+ return scene.min + t * (scene.max - scene.min);
122
+ }
123
+
82
124
  export function buildBarChartScene(series: BarChartSeries[], options: BarChartSceneOptions): BarChartScene | null {
83
125
  const width = Math.max(1, Math.floor(options.width));
84
126
  const height = Math.max(1, Math.floor(options.height));
@@ -91,23 +133,32 @@ export function buildBarChartScene(series: BarChartSeries[], options: BarChartSc
91
133
 
92
134
  const { min, max } = normalizeRange(values);
93
135
  const zeroRow = valueToRow(0, min, max, height);
94
- const groupWidth = Math.max(1, Math.floor(width / categories.length));
95
136
  const seriesCount = Math.max(series.length, 1);
96
- const barWidth = Math.max(1, Math.floor(Math.max(groupWidth - 1, 1) / seriesCount));
137
+ const categorySlots = categories.map((_, index) => categorySlotBounds(width, categories.length, index));
138
+ const minSlotWidth = Math.max(1, Math.min(...categorySlots.map((slot) => slot.width)));
139
+ const groupedInnerWidth = Math.max(seriesCount, minSlotWidth - (minSlotWidth > seriesCount * 2 ? 1 : 0));
140
+ const groupedBarWidth = Math.max(1, Math.floor(groupedInnerWidth / seriesCount));
97
141
  const bars: BarChartBar[] = [];
98
142
 
99
143
  categories.forEach((category, categoryIndex) => {
100
- const groupLeft = categoryIndex * groupWidth;
144
+ const group = categorySlots[categoryIndex]!;
145
+ const barWidth = seriesCount === 1 ? group.width : groupedBarWidth;
146
+ const barGroupWidth = barWidth * seriesCount;
147
+ const groupOffset = Math.max(0, Math.floor((group.width - barGroupWidth) / 2));
101
148
  series.forEach((item, seriesIndex) => {
102
149
  const value = item.points.find((point) => point.category === category)?.value;
103
150
  if (typeof value !== "number" || !Number.isFinite(value)) return;
151
+ const color = value < 0 && options.colors.negativeColor
152
+ ? options.colors.negativeColor
153
+ : item.color;
104
154
  bars.push({
105
155
  seriesId: item.id,
156
+ seriesLabel: item.label,
106
157
  category,
107
158
  value,
108
- color: item.color,
109
- x: clamp(groupLeft + seriesIndex * barWidth, 0, width - 1),
110
- width: Math.min(barWidth, Math.max(width - groupLeft, 1)),
159
+ color,
160
+ x: clamp(group.left + groupOffset + seriesIndex * barWidth, 0, width - 1),
161
+ width: Math.min(barWidth, Math.max(width - (group.left + groupOffset + seriesIndex * barWidth), 1)),
111
162
  row: valueToRow(value, min, max, height),
112
163
  });
113
164
  });
@@ -126,18 +177,81 @@ export function buildBarChartScene(series: BarChartSeries[], options: BarChartSc
126
177
  };
127
178
  }
128
179
 
129
- export function renderBarChart(scene: BarChartScene): string[] {
180
+ function labelAtRow(labels: Array<{ row: number; value: number }>, row: number): number | null {
181
+ const match = labels.find((label) => label.row === row);
182
+ return match ? match.value : null;
183
+ }
184
+
185
+ export function barChartYAxisTicks(scene: BarChartScene): Array<{ row: number; value: number }> {
186
+ const ticksByRow = new Map<number, number>();
187
+ const addTick = (row: number, value: number) => {
188
+ ticksByRow.set(clamp(row, 0, scene.height - 1), value);
189
+ };
190
+
191
+ addTick(0, scene.max);
192
+ for (let i = 1; i <= 3; i++) {
193
+ const row = Math.round((scene.height - 1) * (i / 4));
194
+ addTick(row, rowToValue(row, scene));
195
+ }
196
+ addTick(scene.zeroRow, 0);
197
+ addTick(scene.height - 1, scene.min);
198
+
199
+ return [...ticksByRow.entries()]
200
+ .map(([row, value]) => ({ row, value }))
201
+ .sort((left, right) => left.row - right.row);
202
+ }
203
+
204
+ export function renderBarChartYAxis(
205
+ scene: BarChartScene,
206
+ width: number,
207
+ formatValue: (value: number) => string = formatCompact,
208
+ ): string[] {
209
+ const axisWidth = Math.max(0, Math.floor(width));
210
+ if (axisWidth === 0) return Array.from({ length: scene.height }, () => "");
211
+
212
+ const ticks = barChartYAxisTicks(scene);
213
+ return Array.from({ length: scene.height }, (_unused, row) => {
214
+ const value = labelAtRow(ticks, row);
215
+ const label = value === null ? "" : formatValue(value);
216
+ return `${label.slice(0, Math.max(axisWidth - 1, 0)).padStart(Math.max(axisWidth - 1, 0))} `;
217
+ });
218
+ }
219
+
220
+ export function resolveBarChartHover(scene: BarChartScene, cellX: number): BarChartHover | null {
221
+ if (!Number.isFinite(cellX) || scene.bars.length === 0) return null;
222
+ const x = clamp(Math.floor(cellX), 0, scene.width - 1);
223
+ const direct = scene.bars.find((bar) => x >= bar.x && x < bar.x + bar.width);
224
+ const bar = direct ?? scene.bars.reduce((closest, current) => {
225
+ const closestCenter = closest.x + closest.width / 2;
226
+ const currentCenter = current.x + current.width / 2;
227
+ return Math.abs(currentCenter - x) < Math.abs(closestCenter - x) ? current : closest;
228
+ }, scene.bars[0]!);
229
+
230
+ return {
231
+ seriesId: bar.seriesId,
232
+ seriesLabel: bar.seriesLabel,
233
+ category: bar.category,
234
+ value: bar.value,
235
+ color: bar.color,
236
+ x: bar.x,
237
+ width: bar.width,
238
+ row: bar.row,
239
+ };
240
+ }
241
+
242
+ export function renderBarChart(scene: BarChartScene, hover: BarChartHover | null = null): string[] {
130
243
  const rows = Array.from({ length: scene.height }, () => Array(scene.width).fill(" "));
131
244
  for (let x = 0; x < scene.width; x++) {
132
245
  rows[scene.zeroRow]![x] = "─";
133
246
  }
134
247
 
135
248
  for (const bar of scene.bars) {
249
+ const fill = hover && hover.seriesId === bar.seriesId && hover.category === bar.category ? "▓" : "█";
136
250
  const top = Math.min(bar.row, scene.zeroRow);
137
251
  const bottom = Math.max(bar.row, scene.zeroRow);
138
252
  for (let y = top; y <= bottom; y++) {
139
253
  for (let x = bar.x; x < Math.min(bar.x + bar.width, scene.width); x++) {
140
- rows[y]![x] = "█";
254
+ rows[y]![x] = fill;
141
255
  }
142
256
  }
143
257
  }
@@ -145,26 +259,101 @@ export function renderBarChart(scene: BarChartScene): string[] {
145
259
  return rows.map((row) => row.join(""));
146
260
  }
147
261
 
148
- export function renderBarChartAxis(scene: BarChartScene): string {
149
- const axis = Array(scene.width).fill(" ");
150
- const groupWidth = Math.max(1, Math.floor(scene.width / scene.categories.length));
262
+ function centeredLabelStart(center: number, label: string, width: number, rightInset = 0): number {
263
+ return clamp(Math.round(center - label.length / 2), 0, Math.max(0, width - label.length - rightInset));
264
+ }
265
+
266
+ function compactCategoryLabel(category: string, previous: string | undefined, categoryCount: number): string {
267
+ const quarterMatch = category.match(/^(\d{4}) Q([1-4])$/);
268
+ if (!quarterMatch) return category;
269
+ const year = quarterMatch[1]!;
270
+ const previousYear = previous?.match(/^(\d{4}) Q[1-4]$/)?.[1];
271
+ if (categoryCount > 10) return previousYear === year ? "" : year;
272
+ return category;
273
+ }
274
+
275
+ function canPlaceLabel(row: string[], start: number, label: string, minGap: number): boolean {
276
+ const left = Math.max(0, start - minGap);
277
+ const right = Math.min(row.length - 1, start + label.length + minGap - 1);
278
+ for (let index = left; index <= right; index++) {
279
+ if (row[index] !== " ") return false;
280
+ }
281
+ return true;
282
+ }
283
+
284
+ export function renderBarChartAxis(scene: BarChartScene, rowCount = 1): string[] {
285
+ const rows = Array.from({ length: Math.max(1, Math.floor(rowCount)) }, () => Array(scene.width).fill(" "));
286
+ const minGap = scene.categories.length > 10 ? 3 : 1;
287
+ const rightInset = scene.categories.length > 10 ? 2 : 0;
151
288
  scene.categories.forEach((category, index) => {
152
- const label = category.slice(0, Math.max(1, groupWidth - 1));
153
- const start = index * groupWidth;
289
+ const label = compactCategoryLabel(category, scene.categories[index - 1], scene.categories.length);
290
+ if (!label) return;
291
+ const group = categorySlotBounds(scene.width, scene.categories.length, index);
292
+ const start = centeredLabelStart(group.left + group.width / 2, label, scene.width, rightInset);
293
+ const row = rows.find((candidate) => canPlaceLabel(candidate, start, label, minGap));
294
+ if (!row) return;
154
295
  for (let i = 0; i < label.length && start + i < scene.width; i++) {
155
- axis[start + i] = label[i]!;
296
+ row[start + i] = label[i]!;
156
297
  }
157
298
  });
158
- return axis.join("");
299
+ return rows.map((row) => row.join(""));
159
300
  }
160
301
 
161
- export function renderNativeBarChart(scene: BarChartScene, pixelWidth: number, pixelHeight: number): NativeChartBitmap {
302
+ export function resolveNativeBarPixelBounds(
303
+ scene: BarChartScene,
304
+ bar: BarChartBar,
305
+ pixelWidth: number,
306
+ ): NativeBarChartPixelBounds {
307
+ const width = Math.max(1, Math.floor(pixelWidth));
308
+
309
+ if (scene.series.length === 1) {
310
+ const categoryIndex = scene.categories.indexOf(bar.category);
311
+ if (categoryIndex >= 0) {
312
+ const separatorWidth = scene.categories.length > 1 && width >= scene.categories.length * 3 ? 1 : 0;
313
+ const usableWidth = Math.max(
314
+ scene.categories.length,
315
+ width - separatorWidth * (scene.categories.length - 1),
316
+ );
317
+ const left = Math.floor((categoryIndex * usableWidth) / scene.categories.length)
318
+ + categoryIndex * separatorWidth;
319
+ const rightExclusive = categoryIndex === scene.categories.length - 1
320
+ ? width
321
+ : Math.floor(((categoryIndex + 1) * usableWidth) / scene.categories.length)
322
+ + categoryIndex * separatorWidth;
323
+ return {
324
+ left,
325
+ rightExclusive: Math.max(left + 1, rightExclusive),
326
+ };
327
+ }
328
+ }
329
+
330
+ const horizontalInset = 1;
331
+ const left = clamp(
332
+ Math.floor((bar.x / scene.width) * width + horizontalInset),
333
+ 0,
334
+ width - 1,
335
+ );
336
+ const rightExclusive = clamp(
337
+ Math.ceil(((bar.x + bar.width) / scene.width) * width - horizontalInset),
338
+ left + 1,
339
+ width,
340
+ );
341
+ return { left, rightExclusive };
342
+ }
343
+
344
+ export function renderNativeBarChart(
345
+ scene: BarChartScene,
346
+ pixelWidth: number,
347
+ pixelHeight: number,
348
+ hover: BarChartHover | null = null,
349
+ ): NativeChartBitmap {
162
350
  const width = Math.max(1, Math.floor(pixelWidth));
163
351
  const height = Math.max(1, Math.floor(pixelHeight));
164
352
  const data = new Uint8Array(width * height * 4);
165
353
  const bg = parseHex(scene.colors.bgColor, 1);
166
354
  const grid = parseHex(scene.colors.gridColor, 0.45);
167
355
  const axis = parseHex(scene.colors.axisColor, 0.75);
356
+ const hoverColor = parseHex(scene.colors.hoverColor ?? scene.colors.axisColor, 1);
168
357
 
169
358
  fillRect(data, width, height, 0, 0, width - 1, height - 1, bg, 1);
170
359
 
@@ -177,13 +366,23 @@ export function renderNativeBarChart(scene: BarChartScene, pixelWidth: number, p
177
366
  fillRect(data, width, height, 0, zeroY, width - 1, zeroY + 1.1, axis, 0.8);
178
367
 
179
368
  for (const bar of scene.bars) {
369
+ const isHovered = !!hover
370
+ && hover.seriesId === bar.seriesId
371
+ && hover.category === bar.category;
180
372
  const color = parseHex(bar.color, 0.96);
181
- const left = (bar.x / scene.width) * width + 1;
182
- const right = ((bar.x + bar.width) / scene.width) * width - 1;
373
+ const bounds = resolveNativeBarPixelBounds(scene, bar, width);
374
+ const left = bounds.left;
375
+ const right = bounds.rightExclusive - 1;
183
376
  const valueY = clamp((bar.row / Math.max(scene.height - 1, 1)) * (height - 1), 0, height - 1);
184
377
  const top = Math.min(valueY, zeroY);
185
378
  const bottom = Math.max(valueY, zeroY);
186
- fillRect(data, width, height, left, top, Math.max(left, right), Math.max(top + 1, bottom), color, 1);
379
+ fillRect(data, width, height, left, top, right, Math.max(top + 1, bottom), color, 1);
380
+ if (isHovered) {
381
+ fillRect(data, width, height, left, top, right, top + 1.3, hoverColor, 1);
382
+ fillRect(data, width, height, left, bottom - 1.3, right, bottom, hoverColor, 1);
383
+ fillRect(data, width, height, left, top, left + 1.3, bottom, hoverColor, 1);
384
+ fillRect(data, width, height, right - 1.3, top, right, bottom, hoverColor, 1);
385
+ }
187
386
  }
188
387
 
189
388
  return { width, height, pixels: data };
@@ -28,6 +28,7 @@ import {
28
28
  resolveCursorMotionKind,
29
29
  type ChartMouseEvent,
30
30
  type DisplayCursorState,
31
+ type MouseInteractionEvent,
31
32
  } from "../core/pointer";
32
33
  import type {
33
34
  ComparisonChartSeries,
@@ -48,6 +49,7 @@ interface UseComparisonChartPointerInteractionsOptions {
48
49
  chartWidth: number;
49
50
  commitSelectionCursor: (next: { cursorX: number | null; cursorY: number | null }) => void;
50
51
  expandBufferRange: (action: PendingExpansionAction) => boolean;
52
+ focusPaneForMouseInteraction: (event: MouseInteractionEvent | null | undefined) => void;
51
53
  mouseCrosshairDisabledRef: MutableRefObject<boolean>;
52
54
  plotRef: RefObject<BoxRenderable | null>;
53
55
  pointCount: number;
@@ -65,6 +67,7 @@ export function useComparisonChartPointerInteractions({
65
67
  chartWidth,
66
68
  commitSelectionCursor,
67
69
  expandBufferRange,
70
+ focusPaneForMouseInteraction,
68
71
  mouseCrosshairDisabledRef,
69
72
  plotRef,
70
73
  pointCount,
@@ -113,13 +116,14 @@ export function useComparisonChartPointerInteractions({
113
116
 
114
117
  const handlePlotDown = useCallback((event: ChartMouseEvent) => {
115
118
  consumeChartMouseEvent(event);
119
+ focusPaneForMouseInteraction(null);
116
120
  mouseCrosshairDisabledRef.current = false;
117
121
  if (!syncPointerCursor(event)) return;
118
122
  dragRef.current = {
119
123
  startGlobalX: getGlobalMouseX(event, renderer),
120
124
  startWindow: visibleDateWindow,
121
125
  };
122
- }, [mouseCrosshairDisabledRef, renderer, syncPointerCursor, visibleDateWindow]);
126
+ }, [focusPaneForMouseInteraction, mouseCrosshairDisabledRef, renderer, syncPointerCursor, visibleDateWindow]);
123
127
 
124
128
  const handlePlotDrag = useCallback((event: ChartMouseEvent) => {
125
129
  consumeChartMouseEvent(event);
@@ -147,6 +151,7 @@ export function useComparisonChartPointerInteractions({
147
151
 
148
152
  const handlePlotScroll = useCallback((event: ChartMouseEvent) => {
149
153
  consumeChartMouseEvent(event);
154
+ focusPaneForMouseInteraction(null);
150
155
  const direction = event.scroll?.direction;
151
156
  if (!direction) return;
152
157
  const scrollPan = consumeScrollPanMovement(
@@ -179,6 +184,7 @@ export function useComparisonChartPointerInteractions({
179
184
  chartWidth,
180
185
  dateBounds,
181
186
  expandBufferRange,
187
+ focusPaneForMouseInteraction,
182
188
  scrollPanCellRemainderRef,
183
189
  series,
184
190
  seriesDates,
@@ -1,11 +1,13 @@
1
1
  import type { ComparisonChartProjection } from "../comparison/data";
2
2
  import { PriceReturnTable, type PriceReturnTableRow } from "../../price-performance";
3
3
  import type { PriceReturnField } from "../../../market-data/performance";
4
+ import type { MouseInteractionEvent } from "../core/pointer";
4
5
 
5
6
  interface ComparisonChartLegendProps {
6
7
  legendActiveIndex: number | null;
7
8
  legendItemWidth: number;
8
9
  legendRows: number;
10
+ onFocusInteraction: (event: MouseInteractionEvent | null | undefined) => void;
9
11
  onOpenSymbol: (symbol: string) => void;
10
12
  onSelectSymbol: (symbol: string) => void;
11
13
  performanceBySymbol: ReadonlyMap<string, PriceReturnField[]>;
@@ -36,6 +38,7 @@ export function ComparisonChartLegend({
36
38
  legendActiveIndex,
37
39
  legendItemWidth,
38
40
  legendRows,
41
+ onFocusInteraction,
39
42
  onOpenSymbol,
40
43
  onSelectSymbol,
41
44
  performanceBySymbol,
@@ -61,6 +64,7 @@ export function ComparisonChartLegend({
61
64
  return (
62
65
  <PriceReturnTable
63
66
  height={legendRows}
67
+ onFocusInteraction={onFocusInteraction}
64
68
  onOpenSymbol={onOpenSymbol}
65
69
  onSelectSymbol={onSelectSymbol}
66
70
  rows={rows}
@@ -3,7 +3,8 @@ import {
3
3
  DEFAULT_LIVE_CHART_REFRESH_INTERVAL_MS,
4
4
  useChartQueries,
5
5
  } from "../../../market-data/hooks";
6
- import { buildChartKey } from "../../../market-data/selectors";
6
+ import type { ChartRequest, InstrumentRef } from "../../../market-data/request-types";
7
+ import { buildChartKey, resolveEntryData } from "../../../market-data/selectors";
7
8
  import { getSharedMarketData } from "../../../plugins/registry";
8
9
  import { blendHex, colors, getComparisonSeriesColor } from "../../../theme/colors";
9
10
  import type { BrokerContractRef } from "../../../types/instrument";
@@ -24,6 +25,8 @@ import type {
24
25
  ComparisonChartViewState,
25
26
  } from "../core/types";
26
27
 
28
+ const COMPARISON_PERFORMANCE_RANGE = "5Y" as const;
29
+
27
30
  export interface ComparisonChartSymbolSource {
28
31
  symbol: string;
29
32
  currency: string | undefined;
@@ -40,6 +43,16 @@ interface UseComparisonChartRenderDataOptions {
40
43
  viewState: ComparisonChartViewState;
41
44
  }
42
45
 
46
+ function instrumentFromSymbolSource(source: ComparisonChartSymbolSource): InstrumentRef {
47
+ return {
48
+ symbol: source.symbol,
49
+ exchange: source.exchange,
50
+ brokerId: source.brokerId,
51
+ brokerInstanceId: source.brokerInstanceId,
52
+ instrument: source.instrument,
53
+ };
54
+ }
55
+
43
56
  export function useComparisonChartRenderData({
44
57
  symbolSources,
45
58
  viewState,
@@ -143,21 +156,25 @@ export function useComparisonChartRenderData({
143
156
  );
144
157
  const chartRequests = useMemo(() => (
145
158
  symbolSources.map((source) => ({
146
- instrument: {
147
- symbol: source.symbol,
148
- exchange: source.exchange,
149
- brokerId: source.brokerId,
150
- brokerInstanceId: source.brokerInstanceId,
151
- instrument: source.instrument,
152
- },
159
+ instrument: instrumentFromSymbolSource(source),
153
160
  bufferRange: viewState.bufferRange,
154
161
  granularity: effectiveResolution === "auto" ? "range" as const : "resolution" as const,
155
162
  resolution: effectiveResolution === "auto" ? undefined : effectiveResolution,
156
163
  }))
157
164
  ), [effectiveResolution, symbolSources, viewState.bufferRange]);
165
+ const performanceRequests = useMemo<ChartRequest[]>(() => (
166
+ symbolSources.map((source) => ({
167
+ instrument: instrumentFromSymbolSource(source),
168
+ bufferRange: COMPARISON_PERFORMANCE_RANGE,
169
+ granularity: "range",
170
+ }))
171
+ ), [symbolSources]);
158
172
  const chartEntries = useChartQueries(chartRequests, {
159
173
  refreshIntervalMs: DEFAULT_LIVE_CHART_REFRESH_INTERVAL_MS,
160
174
  });
175
+ const performanceEntries = useChartQueries(performanceRequests, {
176
+ refreshIntervalMs: DEFAULT_LIVE_CHART_REFRESH_INTERVAL_MS,
177
+ });
161
178
  const entryStates = useMemo(() => chartRequests.map((request) => (
162
179
  resolveChartBodyState(chartEntries.get(buildChartKey(request)), (value) => Array.isArray(value) && value.length > 0, "No chart data yet.")
163
180
  )), [chartEntries, chartRequests]);
@@ -182,6 +199,17 @@ export function useComparisonChartRenderData({
182
199
  points,
183
200
  };
184
201
  }), [chartEntries, chartRequests, symbolSources]);
202
+ const performanceHistoryBySymbol = useMemo(() => {
203
+ const bySymbol = new Map<string, PricePoint[]>();
204
+ symbolSources.forEach((source, index) => {
205
+ const request = performanceRequests[index];
206
+ const history = request ? resolveEntryData(performanceEntries.get(buildChartKey(request))) : null;
207
+ if (history?.length) {
208
+ bySymbol.set(source.symbol, history);
209
+ }
210
+ });
211
+ return bySymbol;
212
+ }, [performanceEntries, performanceRequests, symbolSources]);
185
213
 
186
214
  return {
187
215
  availableManualResolutions,
@@ -192,6 +220,7 @@ export function useComparisonChartRenderData({
192
220
  isUpdating,
193
221
  resolutionChips,
194
222
  selectionSupportMap,
223
+ performanceHistoryBySymbol,
195
224
  series,
196
225
  supportMap,
197
226
  };
@@ -1,6 +1,7 @@
1
- import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
1
+ import { useCallback, useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
2
2
  import type { BoxRenderable, NativeRendererHost } from "../../../ui";
3
- import { buildDisplayCursorState } from "../core/pointer";
3
+ import { useAppDispatch, usePaneInstanceId } from "../../../state/app/context";
4
+ import { buildDisplayCursorState, type MouseInteractionEvent } from "../core/pointer";
4
5
  import type { ComparisonChartViewState } from "../core/types";
5
6
  import { useChartDisplayCursor, useChartDisplayCursorLayoutRemap } from "../display-cursor";
6
7
  import { clamp } from "./helpers";
@@ -10,6 +11,7 @@ interface UseComparisonChartSelectionRuntimeOptions {
10
11
  chartWidth: number;
11
12
  cursorX: number | null;
12
13
  cursorY: number | null;
14
+ focused: boolean;
13
15
  renderer: NativeRendererHost;
14
16
  setViewState: Dispatch<SetStateAction<ComparisonChartViewState>>;
15
17
  symbols: string[];
@@ -20,6 +22,7 @@ export function useComparisonChartSelectionRuntime({
20
22
  chartWidth,
21
23
  cursorX: viewCursorX,
22
24
  cursorY: viewCursorY,
25
+ focused,
23
26
  renderer,
24
27
  setViewState,
25
28
  symbols,
@@ -28,6 +31,7 @@ export function useComparisonChartSelectionRuntime({
28
31
  displayCursor: ReturnType<typeof useChartDisplayCursor>["displayCursor"];
29
32
  displayCursorX: number | null;
30
33
  displayCursorY: number | null;
34
+ focusPaneForMouseInteraction: (event: MouseInteractionEvent | null | undefined) => void;
31
35
  mouseCrosshairDisabledRef: MutableRefObject<boolean>;
32
36
  plotRef: MutableRefObject<BoxRenderable | null>;
33
37
  scrollPanCellRemainderRef: MutableRefObject<number>;
@@ -35,6 +39,8 @@ export function useComparisonChartSelectionRuntime({
35
39
  setSelectedSymbol: (symbol: string) => void;
36
40
  updateDisplayCursorTarget: ReturnType<typeof useChartDisplayCursor>["updateDisplayCursorTarget"];
37
41
  } {
42
+ const dispatch = useAppDispatch();
43
+ const paneId = usePaneInstanceId();
38
44
  const plotRef = useRef<BoxRenderable | null>(null);
39
45
  const mouseCrosshairDisabledRef = useRef(false);
40
46
  const scrollPanCellRemainderRef = useRef(0);
@@ -52,6 +58,16 @@ export function useComparisonChartSelectionRuntime({
52
58
  const displayCursorX = displayCursor.cellX !== null ? clamp(displayCursor.cellX, 0, chartWidth - 1) : null;
53
59
  const displayCursorY = displayCursor.cellY !== null ? clamp(displayCursor.cellY, 0, chartHeight - 1) : null;
54
60
 
61
+ const focusPaneForMouseInteraction = useCallback((
62
+ event: MouseInteractionEvent | null | undefined,
63
+ ) => {
64
+ event?.stopPropagation?.();
65
+ event?.preventDefault?.();
66
+ if (!focused) {
67
+ dispatch({ type: "FOCUS_PANE", paneId });
68
+ }
69
+ }, [dispatch, focused, paneId]);
70
+
55
71
  const setSelectedSymbol = (symbol: string) => {
56
72
  setViewState((current) => (
57
73
  current.selectedSymbol === symbol
@@ -101,6 +117,7 @@ export function useComparisonChartSelectionRuntime({
101
117
  displayCursor,
102
118
  displayCursorX,
103
119
  displayCursorY,
120
+ focusPaneForMouseInteraction,
104
121
  mouseCrosshairDisabledRef,
105
122
  plotRef,
106
123
  scrollPanCellRemainderRef,
@@ -24,17 +24,21 @@ export function useComparisonChartSymbolSources(symbols: string[]): {
24
24
  const marketFinancials = useTickerFinancialsMap(stableTickers);
25
25
  const symbolSources = useMemo<ComparisonChartSymbolSource[]>(() => stableSymbols.map((symbol) => {
26
26
  const ticker = tickers.get(symbol) ?? null;
27
- const financial = marketFinancials.get(symbol) ?? financials.get(symbol) ?? null;
27
+ const marketFinancial = marketFinancials.get(symbol) ?? null;
28
+ const storedFinancial = financials.get(symbol) ?? null;
28
29
  const instrument = ticker?.metadata.broker_contracts?.[0] ?? null;
30
+ const quote = marketFinancial?.quote ?? storedFinancial?.quote;
29
31
  return {
30
32
  symbol,
31
- currency: financial?.quote?.currency ?? ticker?.metadata.currency,
32
- quote: financial?.quote,
33
+ currency: quote?.currency ?? ticker?.metadata.currency,
34
+ quote,
33
35
  exchange: ticker?.metadata.exchange ?? "",
34
36
  brokerId: instrument?.brokerId,
35
37
  brokerInstanceId: instrument?.brokerInstanceId,
36
38
  instrument,
37
- priceHistory: financial?.priceHistory ?? [],
39
+ priceHistory: marketFinancial?.priceHistory?.length
40
+ ? marketFinancial.priceHistory
41
+ : storedFinancial?.priceHistory ?? [],
38
42
  };
39
43
  }), [financials, marketFinancials, stableSymbols, tickers]);
40
44