gloomberb 0.4.0 → 0.5.0

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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +296 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +33 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -0,0 +1,104 @@
1
+ import { clearActivePreset } from "./chart-controller";
2
+ import type { TimeRange } from "./chart-types";
3
+
4
+ export type ChartScrollDirection = "up" | "down" | "left" | "right";
5
+
6
+ const KEYBOARD_PAN_WIDTH_RATIO = 0.02;
7
+ const SCROLL_PAN_WIDTH_RATIO = 0.005;
8
+ const DRAG_PAN_VISIBLE_RATIO = 0.2;
9
+
10
+ interface BufferExpansionViewState {
11
+ activePreset: TimeRange | null;
12
+ bufferRange: TimeRange;
13
+ }
14
+
15
+ export interface ScrollPanCellDelta {
16
+ cells: number;
17
+ remainder: number;
18
+ }
19
+
20
+ export interface ScrollPanMovement extends ScrollPanCellDelta {
21
+ ratio: number;
22
+ }
23
+
24
+ function clamp(value: number, min: number, max: number): number {
25
+ return Math.max(min, Math.min(max, value));
26
+ }
27
+
28
+ export function getKeyboardPanCellCount(chartWidth: number): number {
29
+ return Math.max(Math.round(chartWidth * KEYBOARD_PAN_WIDTH_RATIO), 1);
30
+ }
31
+
32
+ export function getDragPanPointDelta(deltaCells: number, chartWidth: number, visibleCount: number): number {
33
+ return Math.round((deltaCells / Math.max(chartWidth, 1)) * Math.max(visibleCount, 1) * DRAG_PAN_VISIBLE_RATIO);
34
+ }
35
+
36
+ export function getDragPanWindowRatio(deltaCells: number, chartWidth: number): number {
37
+ return (deltaCells / Math.max(chartWidth, 1)) * DRAG_PAN_VISIBLE_RATIO;
38
+ }
39
+
40
+ export function resolveDragPanOffset(
41
+ startPanOffset: number,
42
+ deltaCells: number,
43
+ chartWidth: number,
44
+ visibleCount: number,
45
+ maxPanOffset: number,
46
+ ): number {
47
+ return clamp(
48
+ startPanOffset - getDragPanPointDelta(deltaCells, chartWidth, visibleCount),
49
+ 0,
50
+ Math.max(maxPanOffset, 0),
51
+ );
52
+ }
53
+
54
+ export function consumeScrollPanCellDelta(
55
+ chartWidth: number,
56
+ delta: number | undefined,
57
+ direction: 1 | -1,
58
+ remainder: number,
59
+ ): ScrollPanCellDelta {
60
+ const rawMagnitude = Math.abs(delta ?? 1);
61
+ const magnitude = rawMagnitude > 0 ? rawMagnitude : 1;
62
+ const next = remainder + direction * Math.max(chartWidth, 1) * SCROLL_PAN_WIDTH_RATIO * magnitude;
63
+ const rawCells = next >= 0 ? Math.floor(next) : Math.ceil(next);
64
+ const cells = Object.is(rawCells, -0) ? 0 : rawCells;
65
+ return {
66
+ cells,
67
+ remainder: next - cells,
68
+ };
69
+ }
70
+
71
+ export function consumeScrollPanMovement(
72
+ chartWidth: number,
73
+ delta: number | undefined,
74
+ scrollDirection: ChartScrollDirection,
75
+ remainder: number,
76
+ ): ScrollPanMovement {
77
+ const panDirection = resolveHorizontalScrollPanDirection(scrollDirection);
78
+ const result = consumeScrollPanCellDelta(chartWidth, delta, panDirection, remainder);
79
+ return {
80
+ ...result,
81
+ ratio: result.cells / Math.max(chartWidth, 1),
82
+ };
83
+ }
84
+
85
+ export function applyBufferedPanExpansion<S extends BufferExpansionViewState>(
86
+ view: S,
87
+ nextBufferRange: TimeRange,
88
+ ): S {
89
+ return {
90
+ ...clearActivePreset(view),
91
+ bufferRange: nextBufferRange,
92
+ };
93
+ }
94
+
95
+ export function resolveHorizontalScrollPanDirection(direction: ChartScrollDirection): 1 | -1 {
96
+ switch (direction) {
97
+ case "up":
98
+ case "left":
99
+ return 1;
100
+ case "down":
101
+ case "right":
102
+ return -1;
103
+ }
104
+ }
@@ -88,3 +88,15 @@ export interface ComparisonChartViewState {
88
88
  renderMode?: ComparisonChartRenderMode;
89
89
  selectedSymbol: string | null;
90
90
  }
91
+
92
+ import type { IndicatorConfig, OverlayPoint, OscillatorPoint, MacdResult, BollingerResult } from "./indicators/types";
93
+
94
+ export interface ChartIndicatorOverlays {
95
+ smaLines: { period: number; points: OverlayPoint[]; color: string }[];
96
+ emaLines: { period: number; points: OverlayPoint[]; color: string }[];
97
+ bollinger: (BollingerResult & { color: string }) | null;
98
+ rsi: OscillatorPoint[] | null;
99
+ macd: MacdResult | null;
100
+ }
101
+
102
+ export type { IndicatorConfig };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { RIGHT_EDGE_ANCHOR_RATIO, resolveAnchoredChartZoom } from "./chart-viewport";
2
+ import { getVisiblePointCount, RIGHT_EDGE_ANCHOR_RATIO, resolveAnchoredChartZoom } from "./chart-viewport";
3
3
 
4
4
  describe("resolveAnchoredChartZoom", () => {
5
5
  test("keeps keyboard zoom pinned to the latest data when anchored to the right edge", () => {
@@ -22,4 +22,8 @@ describe("resolveAnchoredChartZoom", () => {
22
22
  panOffset: 63,
23
23
  });
24
24
  });
25
+
26
+ test("allows zooming down to a two-point viewport", () => {
27
+ expect(getVisiblePointCount(433, 1000)).toBe(2);
28
+ });
25
29
  });
@@ -1,4 +1,4 @@
1
- const MIN_VISIBLE_POINTS = 10;
1
+ const MIN_VISIBLE_POINTS = 2;
2
2
  export const RIGHT_EDGE_ANCHOR_RATIO = 1;
3
3
 
4
4
  function clamp(value: number, min: number, max: number): number {
@@ -1,7 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { bucketOhlcSeries, getVisibleWindow, projectChartData, resolveRenderMode } from "./chart-data";
2
+ import {
3
+ bucketOhlcSeries,
4
+ getVisibleWindow,
5
+ projectChartData,
6
+ resolveRenderMode,
7
+ resolveStableOhlcProjectionOptions,
8
+ } from "./chart-data";
3
9
  import { stepCursorTowards } from "./cursor-motion";
4
- import { buildTimeAxis, formatAxisValue, renderChart, resolveChartPalette } from "./chart-renderer";
10
+ import { buildChartScene, buildTimeAxis, formatAxisValue, formatCursorAxisValue, renderChart, resolveChartAxisWidth, resolveChartPalette } from "./chart-renderer";
5
11
  import type { PricePoint } from "../../types/financials";
6
12
  import type { ChartRenderMode, ChartViewState } from "./chart-types";
7
13
 
@@ -63,6 +69,111 @@ describe("bucketOhlcSeries", () => {
63
69
  volume: 450,
64
70
  });
65
71
  });
72
+
73
+ test("keeps interior OHLC buckets aligned to source bars while panning", () => {
74
+ const source = Array.from({ length: 13 }, (_, index) => ({
75
+ date: new Date(Date.UTC(2024, 0, index + 1)),
76
+ open: index,
77
+ high: index + 10,
78
+ low: index - 10,
79
+ close: index + 0.5,
80
+ volume: index + 1,
81
+ })) as PricePoint[];
82
+
83
+ const firstView = bucketOhlcSeries(source.slice(0, 12), 3, { sourceIndexOffset: 0 });
84
+ const pannedView = bucketOhlcSeries(source.slice(1, 13), 3, { sourceIndexOffset: 1 });
85
+ const firstSharedBucket = firstView[1];
86
+ const secondSharedBucket = firstView[2];
87
+ if (!firstSharedBucket || !secondSharedBucket) {
88
+ throw new Error("Expected shared OHLC buckets");
89
+ }
90
+
91
+ expect(firstSharedBucket).toEqual({
92
+ date: source[4]!.date,
93
+ open: 1,
94
+ high: 14,
95
+ low: -9,
96
+ close: 4.5,
97
+ volume: 2 + 3 + 4 + 5,
98
+ });
99
+ expect(pannedView[0]).toEqual(firstSharedBucket);
100
+ expect(pannedView[1]).toEqual(secondSharedBucket);
101
+ });
102
+
103
+ test("keeps source-aligned bucket count stable across small pans", () => {
104
+ const source: PricePoint[] = [];
105
+ let cursor = new Date(Date.UTC(2024, 0, 1));
106
+ let sourceIndex = 0;
107
+ while (source.length < 220) {
108
+ const day = cursor.getUTCDay();
109
+ if (day !== 0 && day !== 6) {
110
+ source.push({
111
+ date: new Date(cursor),
112
+ open: sourceIndex,
113
+ high: sourceIndex + 1,
114
+ low: sourceIndex - 1,
115
+ close: sourceIndex + 0.5,
116
+ volume: sourceIndex + 1,
117
+ });
118
+ sourceIndex += 1;
119
+ }
120
+ cursor = new Date(cursor.getTime() + 24 * 3600_000);
121
+ }
122
+
123
+ const visibleCount = 160;
124
+ const targetWidth = 40;
125
+ const projectionOptions = resolveStableOhlcProjectionOptions({
126
+ pointCount: visibleCount,
127
+ sourceIndexOffset: 0,
128
+ bucketWidth: targetWidth * 2,
129
+ navigationPointCount: visibleCount,
130
+ });
131
+ const targetBucketCount = projectionOptions.ohlcTargetBucketCount!;
132
+ const projectWindow = (start: number) => bucketOhlcSeries(
133
+ source.slice(start, start + visibleCount),
134
+ targetWidth,
135
+ {
136
+ ...projectionOptions,
137
+ sourceIndexOffset: start,
138
+ },
139
+ );
140
+
141
+ const first = projectWindow(0);
142
+ const pannedOne = projectWindow(1);
143
+ const pannedTwo = projectWindow(2);
144
+
145
+ expect(first).toHaveLength(targetBucketCount);
146
+ expect(pannedOne).toHaveLength(targetBucketCount);
147
+ expect(pannedTwo).toHaveLength(targetBucketCount);
148
+ expect(pannedOne[1]!).toEqual(first[1]!);
149
+ expect(pannedTwo[1]!).toEqual(first[1]!);
150
+ });
151
+
152
+ test("plans stable OHLC buckets from navigation count when histories are comparable", () => {
153
+ expect(resolveStableOhlcProjectionOptions({
154
+ pointCount: 158,
155
+ sourceIndexOffset: 12,
156
+ bucketWidth: 80,
157
+ navigationPointCount: 160,
158
+ })).toMatchObject({
159
+ ohlcBucketWidth: 80,
160
+ ohlcSourceBucketSize: 4,
161
+ ohlcTargetBucketCount: 40,
162
+ sourceIndexOffset: 12,
163
+ });
164
+ });
165
+
166
+ test("plans OHLC buckets from rendered points when navigation count is too different", () => {
167
+ expect(resolveStableOhlcProjectionOptions({
168
+ pointCount: 84,
169
+ sourceIndexOffset: 0,
170
+ bucketWidth: 80,
171
+ navigationPointCount: 160,
172
+ })).toMatchObject({
173
+ ohlcSourceBucketSize: 3,
174
+ ohlcTargetBucketCount: 28,
175
+ });
176
+ });
66
177
  });
67
178
 
68
179
  describe("resolveRenderMode", () => {
@@ -90,17 +201,38 @@ describe("resolveRenderMode", () => {
90
201
  volume: 1_000 + i,
91
202
  })) as PricePoint[];
92
203
 
93
- expect(projectChartData(denseSeries, 80, "candles", false).points).toHaveLength(40);
94
- expect(projectChartData(denseSeries, 80, "ohlc", false).points).toHaveLength(40);
204
+ expect(projectChartData(denseSeries, 80, "candles", false).points).toHaveLength(34);
205
+ expect(projectChartData(denseSeries, 80, "ohlc", false).points).toHaveLength(34);
95
206
  expect(projectChartData(denseSeries, 80, "line", false).points).toHaveLength(80);
96
207
  });
208
+
209
+ test("keeps candle buckets stable when axis width changes the plot by one column", () => {
210
+ const denseSeries = Array.from({ length: 121 }, (_, index) => ({
211
+ date: new Date(Date.UTC(2024, 0, index + 1)),
212
+ open: 100 + index,
213
+ high: 101 + index,
214
+ low: 99 + index,
215
+ close: 100.5 + index,
216
+ volume: 1_000 + index,
217
+ })) as PricePoint[];
218
+
219
+ const wide = projectChartData(denseSeries, 80, "candles", false, { ohlcBucketWidth: 80 }).points;
220
+ const narrowed = projectChartData(denseSeries, 78, "candles", false, { ohlcBucketWidth: 80 }).points;
221
+
222
+ expect(narrowed.map((point) => point.date.toISOString())).toEqual(wide.map((point) => point.date.toISOString()));
223
+ expect(narrowed.map((point) => point.open)).toEqual(wide.map((point) => point.open));
224
+ expect(narrowed.map((point) => point.close)).toEqual(wide.map((point) => point.close));
225
+ });
97
226
  });
98
227
 
99
228
  describe("getVisibleWindow", () => {
100
229
  test("shows the full selected range at default zoom and lets the renderer downsample it", () => {
101
230
  const history = buildDenseHistory(252);
102
231
  const viewState: ChartViewState = {
103
- timeRange: "1Y",
232
+ presetRange: "1Y",
233
+ bufferRange: "1Y",
234
+ activePreset: "1Y",
235
+ resolution: "auto",
104
236
  panOffset: 0,
105
237
  zoomLevel: 1,
106
238
  cursorX: null,
@@ -110,14 +242,17 @@ describe("getVisibleWindow", () => {
110
242
  const window = getVisibleWindow(history, viewState, 80);
111
243
 
112
244
  expect(window.points).toHaveLength(history.length);
113
- expect(window.points[0]?.date.toISOString()).toBe(history[0]?.date.toISOString());
114
- expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)?.date.toISOString());
245
+ expect(window.points[0]?.date.toISOString()).toBe(history[0]!.date.toISOString());
246
+ expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)!.date.toISOString());
115
247
  });
116
248
 
117
249
  test("zooms into the selected range instead of pinning the default view to chart width", () => {
118
250
  const history = buildDenseHistory(252);
119
251
  const viewState: ChartViewState = {
120
- timeRange: "1Y",
252
+ presetRange: "1Y",
253
+ bufferRange: "1Y",
254
+ activePreset: "1Y",
255
+ resolution: "auto",
121
256
  panOffset: 0,
122
257
  zoomLevel: 2,
123
258
  cursorX: null,
@@ -127,15 +262,118 @@ describe("getVisibleWindow", () => {
127
262
  const window = getVisibleWindow(history, viewState, 80);
128
263
 
129
264
  expect(window.points).toHaveLength(126);
130
- expect(window.points[0]?.date.toISOString()).toBe(history[126]?.date.toISOString());
131
- expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)?.date.toISOString());
265
+ expect(window.points[0]?.date.toISOString()).toBe(history[126]!.date.toISOString());
266
+ expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)!.date.toISOString());
132
267
  });
133
268
  });
134
269
 
135
270
  describe("renderChart", () => {
271
+ test("sizes the y-axis to the visible label width instead of a fixed gutter", () => {
272
+ expect(resolveChartAxisWidth(["$18", "$17", "$16", "$14"], 4, 10)).toBe(4);
273
+ expect(resolveChartAxisWidth(["$1.1678", "$1.1654"], 4, 10)).toBe(7);
274
+ expect(resolveChartAxisWidth(["+0.25%", "-0.10%"], 5, 11)).toBe(6);
275
+ });
276
+
136
277
  test("formats price axes with the instrument currency", () => {
137
278
  expect(formatAxisValue(21_970, "price", 0, "JPY")).toBe("¥22.0K");
138
279
  expect(formatAxisValue(12_340, "price", 0, "HKD")).toBe("HK$12.3K");
280
+ expect(formatAxisValue(1.17364, "price", 0, "USD", "CURRENCY")).toBe("$1.17364");
281
+ });
282
+
283
+ test("adapts FX axis precision to the visible price range", () => {
284
+ expect(formatAxisValue(1.167815, "price", 0, "USD", "CURRENCY", 0.12)).toBe("$1.17");
285
+ expect(formatAxisValue(1.167815, "price", 0, "USD", "CURRENCY", 0.0024)).toBe("$1.1678");
286
+ });
287
+
288
+ test("keeps the active price-axis label more precise than coarse grid ticks", () => {
289
+ expect(formatAxisValue(21.184, "price", 0, "USD", "STK", 11)).toBe("$21");
290
+ expect(formatCursorAxisValue(21.184, "price", 0, "USD", "STK", 11)).toBe("$21.18");
291
+ expect(formatCursorAxisValue(1.167815, "price", 0, "USD", "CURRENCY", 0.12)).toBe("$1.1678");
292
+ });
293
+
294
+ test("keeps zoomed equity axis labels distinct and decimal-aware", () => {
295
+ const narrowRangeFixture: PricePoint[] = [
296
+ { date: new Date("2024-01-02T09:30:00Z"), close: 18.02, volume: 100 },
297
+ { date: new Date("2024-01-02T09:31:00Z"), close: 17.91, volume: 120 },
298
+ { date: new Date("2024-01-02T09:32:00Z"), close: 17.84, volume: 140 },
299
+ { date: new Date("2024-01-02T09:33:00Z"), close: 17.73, volume: 160 },
300
+ ];
301
+
302
+ const result = renderChart(projectChartData(narrowRangeFixture, 32, "area", false).points, {
303
+ width: 32,
304
+ height: 8,
305
+ showVolume: false,
306
+ volumeHeight: 0,
307
+ cursorX: null,
308
+ cursorY: null,
309
+ mode: "area",
310
+ axisMode: "price",
311
+ currency: "USD",
312
+ assetCategory: "STK",
313
+ colors: palette,
314
+ });
315
+
316
+ expect(result.axisFractionDigits).toBeGreaterThanOrEqual(1);
317
+ expect(new Set(result.axisLabels.map((entry) => entry.label)).size).toBe(result.axisLabels.length);
318
+ expect(result.axisLabels.every((entry) => entry.label.includes("."))).toBe(true);
319
+ });
320
+
321
+ test("includes visible indicator overlays in the chart price range", () => {
322
+ const projection = projectChartData(chartFixture, 12, "line", false);
323
+ const scene = buildChartScene(projection.points, {
324
+ width: 12,
325
+ height: 6,
326
+ showVolume: false,
327
+ volumeHeight: 0,
328
+ cursorX: null,
329
+ cursorY: null,
330
+ mode: projection.effectiveMode,
331
+ colors: palette,
332
+ indicators: {
333
+ smaLines: [{
334
+ period: 2,
335
+ color: "#ff00ff",
336
+ points: [
337
+ { index: 0, value: 6 },
338
+ { index: 1, value: 18 },
339
+ ],
340
+ }],
341
+ emaLines: [],
342
+ bollinger: null,
343
+ rsi: null,
344
+ macd: null,
345
+ },
346
+ });
347
+
348
+ expect(scene?.min).toBe(6);
349
+ expect(scene?.max).toBe(18);
350
+ });
351
+
352
+ test("keeps one decimal on zoomed equity axes even when whole-dollar ticks are distinct", () => {
353
+ const mediumRangeFixture: PricePoint[] = [
354
+ { date: new Date("2024-01-02T09:30:00Z"), close: 233.82, volume: 100 },
355
+ { date: new Date("2024-01-02T09:45:00Z"), close: 232.14, volume: 120 },
356
+ { date: new Date("2024-01-02T10:00:00Z"), close: 230.21, volume: 140 },
357
+ { date: new Date("2024-01-02T10:15:00Z"), close: 231.67, volume: 160 },
358
+ { date: new Date("2024-01-02T10:30:00Z"), close: 228.94, volume: 180 },
359
+ ];
360
+
361
+ const result = renderChart(projectChartData(mediumRangeFixture, 28, "area", false).points, {
362
+ width: 28,
363
+ height: 8,
364
+ showVolume: false,
365
+ volumeHeight: 0,
366
+ cursorX: null,
367
+ cursorY: null,
368
+ mode: "area",
369
+ axisMode: "price",
370
+ currency: "USD",
371
+ assetCategory: "STK",
372
+ colors: palette,
373
+ });
374
+
375
+ expect(result.axisFractionDigits).toBe(1);
376
+ expect(result.axisLabels.every((entry) => entry.label.includes("."))).toBe(true);
139
377
  });
140
378
 
141
379
  test("eases coarse cursor motion quickly and settles without overshooting", () => {
@@ -409,6 +647,7 @@ describe("renderChart", () => {
409
647
  showVolume: false,
410
648
  volumeHeight: 0,
411
649
  cursorX: null,
650
+ cursorY: null,
412
651
  mode: projection.effectiveMode,
413
652
  axisMode: "percent",
414
653
  colors: palette,
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { projectComparisonChartData } from "./comparison-chart-data";
3
- import { buildComparisonChartScene, renderComparisonChart } from "./comparison-chart-renderer";
3
+ import {
4
+ buildComparisonChartScene,
5
+ formatComparisonAxisValue,
6
+ formatComparisonCursorAxisValue,
7
+ renderComparisonChart,
8
+ } from "./comparison-chart-renderer";
4
9
  import type { ComparisonChartSeries } from "./chart-types";
5
10
 
6
11
  function makeSeries(symbol: string, color: string, closes: number[]): ComparisonChartSeries {
@@ -21,6 +26,71 @@ function textLines(result: ReturnType<typeof renderComparisonChart>): string[] {
21
26
  }
22
27
 
23
28
  describe("renderComparisonChart", () => {
29
+ test("adapts price-axis precision to the visible range", () => {
30
+ expect(formatComparisonAxisValue(1.167815, "price", 0.12)).toBe("1.17");
31
+ expect(formatComparisonAxisValue(1.167815, "price", 0.0024)).toBe("1.1678");
32
+ });
33
+
34
+ test("keeps the active comparison-axis label more precise than coarse ticks", () => {
35
+ expect(formatComparisonAxisValue(21.184, "price", 11)).toBe("21");
36
+ expect(formatComparisonCursorAxisValue(21.184, "price", 11)).toBe("21.18");
37
+ });
38
+
39
+ test("keeps narrow comparison price axes distinct", () => {
40
+ const projection = projectComparisonChartData([
41
+ makeSeries("AAPL", "#00ff00", [18.02, 17.91, 17.84, 17.73]),
42
+ ], 24, {
43
+ timeRange: "ALL",
44
+ panOffset: 0,
45
+ zoomLevel: 1,
46
+ renderMode: "line",
47
+ }, "price");
48
+
49
+ const result = renderComparisonChart(projection, {
50
+ width: 24,
51
+ height: 6,
52
+ cursorX: null,
53
+ cursorY: null,
54
+ selectedSymbol: "AAPL",
55
+ colors: {
56
+ bgColor: "#000000",
57
+ gridColor: "#333333",
58
+ crosshairColor: "#ffffff",
59
+ },
60
+ });
61
+
62
+ expect(result.axisFractionDigits).toBeGreaterThanOrEqual(1);
63
+ expect(new Set(result.axisLabels.map((entry) => entry.label)).size).toBe(result.axisLabels.length);
64
+ expect(result.axisLabels.every((entry) => entry.label.includes("."))).toBe(true);
65
+ });
66
+
67
+ test("keeps one decimal on zoomed comparison price axes even when whole ticks are distinct", () => {
68
+ const projection = projectComparisonChartData([
69
+ makeSeries("AAPL", "#00ff00", [233.82, 232.14, 230.21, 231.67, 228.94]),
70
+ ], 24, {
71
+ timeRange: "ALL",
72
+ panOffset: 0,
73
+ zoomLevel: 1,
74
+ renderMode: "line",
75
+ }, "price");
76
+
77
+ const result = renderComparisonChart(projection, {
78
+ width: 24,
79
+ height: 6,
80
+ cursorX: null,
81
+ cursorY: null,
82
+ selectedSymbol: "AAPL",
83
+ colors: {
84
+ bgColor: "#000000",
85
+ gridColor: "#333333",
86
+ crosshairColor: "#ffffff",
87
+ },
88
+ });
89
+
90
+ expect(result.axisFractionDigits).toBe(1);
91
+ expect(result.axisLabels.every((entry) => entry.label.includes("."))).toBe(true);
92
+ });
93
+
24
94
  test("renders a shared multi-series line chart", () => {
25
95
  const projection = projectComparisonChartData([
26
96
  makeSeries("AAPL", "#00ff00", [10, 12, 11, 13]),
@@ -12,6 +12,7 @@ import {
12
12
  drawGridLines,
13
13
  drawLine,
14
14
  formatPrice,
15
+ resolveAxisFractionDigits,
15
16
  type StyledContent,
16
17
  } from "./chart-renderer";
17
18
  import type { ChartAxisMode, ComparisonChartRenderMode } from "./chart-types";
@@ -71,6 +72,8 @@ export interface RenderComparisonChartResult {
71
72
  crosshairValue: number | null;
72
73
  cursorColumn: number | null;
73
74
  cursorRow: number | null;
75
+ axisFractionDigits: number | null;
76
+ priceRange: number | null;
74
77
  }
75
78
 
76
79
  function clamp(value: number, min: number, max: number): number {
@@ -95,10 +98,32 @@ function formatPercentAxisValue(value: number): string {
95
98
  return `${prefix}${value.toFixed(decimals)}%`;
96
99
  }
97
100
 
98
- export function formatComparisonAxisValue(value: number, axisMode: ChartAxisMode): string {
101
+ function getComparisonAxisFractionDigitFloor(priceRange: number | undefined): number {
102
+ if (priceRange === undefined || !Number.isFinite(priceRange) || priceRange <= 0) return 0;
103
+ const visibleStep = priceRange / 3;
104
+ if (!Number.isFinite(visibleStep) || visibleStep <= 0) return 0;
105
+ return Math.max(0, Math.ceil(-Math.log10(visibleStep)) + 1);
106
+ }
107
+
108
+ export function formatComparisonAxisValue(
109
+ value: number,
110
+ axisMode: ChartAxisMode,
111
+ priceRange?: number,
112
+ fixedFractionDigits?: number,
113
+ ): string {
114
+ return axisMode === "percent"
115
+ ? formatPercentAxisValue(value)
116
+ : formatPrice(value, undefined, priceRange, 0, 0, fixedFractionDigits);
117
+ }
118
+
119
+ export function formatComparisonCursorAxisValue(
120
+ value: number,
121
+ axisMode: ChartAxisMode,
122
+ priceRange?: number,
123
+ ): string {
99
124
  return axisMode === "percent"
100
125
  ? formatPercentAxisValue(value)
101
- : formatPrice(value);
126
+ : formatPrice(value, undefined, priceRange, 2, Math.abs(value) >= 1 ? 2 : 4);
102
127
  }
103
128
 
104
129
  function fillColumn(
@@ -305,13 +330,23 @@ export function renderComparisonChart(
305
330
  crosshairValue: null,
306
331
  cursorColumn: null,
307
332
  cursorRow: null,
333
+ axisFractionDigits: null,
334
+ priceRange: null,
308
335
  };
309
336
  }
310
337
 
311
338
  const dotWidth = scene.width * 2;
312
339
  const chartDotBottom = scene.chartRows * 4 - 1;
340
+ const priceRange = scene.max - scene.min;
313
341
  const buf = createPixelBuffer(dotWidth, scene.height * 4);
314
342
  const gridLines = computeGridLines(scene.min, scene.max, 0, chartDotBottom, 3);
343
+ const axisFractionDigits = scene.axisMode === "price"
344
+ ? resolveAxisFractionDigits(
345
+ gridLines.map((line) => line.price),
346
+ (price, fixedFractionDigits) => formatComparisonAxisValue(price, scene.axisMode, priceRange, fixedFractionDigits),
347
+ getComparisonAxisFractionDigitFloor(priceRange),
348
+ )
349
+ : null;
315
350
  drawGridLines(buf, gridLines.map((line) => line.y), scene.colors.gridColor);
316
351
 
317
352
  const selectedSeries = getSelectedSeries(scene.series, scene.selectedSymbol);
@@ -336,7 +371,7 @@ export function renderComparisonChart(
336
371
  for (const line of gridLines) {
337
372
  axisLabelsByRow.set(
338
373
  Math.min(Math.floor(line.y / 4), Math.max(scene.chartRows - 1, 0)),
339
- formatComparisonAxisValue(line.price, scene.axisMode),
374
+ formatComparisonAxisValue(line.price, scene.axisMode, priceRange, axisFractionDigits ?? undefined),
340
375
  );
341
376
  }
342
377
 
@@ -350,5 +385,7 @@ export function renderComparisonChart(
350
385
  crosshairValue: scene.crosshairValue,
351
386
  cursorColumn: scene.cursorColumn,
352
387
  cursorRow: scene.cursorRow,
388
+ axisFractionDigits,
389
+ priceRange,
353
390
  };
354
391
  }