gloomberb 0.7.2 → 0.7.3

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 (129) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -2,14 +2,14 @@ import type { PricePoint } from "../../types/financials";
2
2
 
3
3
  export type TimeRange = "1D" | "1W" | "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL";
4
4
  export type ChartResolution = "auto" | "1m" | "5m" | "15m" | "30m" | "45m" | "1h" | "1d" | "1wk" | "1mo";
5
- export type ChartRenderMode = "area" | "line" | "candles" | "ohlc";
5
+ export type ChartRenderMode = "area" | "line" | "candles" | "ohlc" | "hlc";
6
6
  export type ChartAxisMode = "price" | "percent";
7
7
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
8
8
  export type ResolvedChartRenderer = "kitty" | "braille";
9
9
  export type ComparisonChartRenderMode = "area" | "line";
10
10
 
11
11
  export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
12
- export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc"];
12
+ export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc", "hlc"];
13
13
  export const CHART_RENDERER_PREFERENCES: ChartRendererPreference[] = ["auto", "kitty", "braille"];
14
14
  export const COMPARISON_RENDER_MODES: ComparisonChartRenderMode[] = ["area", "line"];
15
15
 
@@ -183,6 +183,8 @@ describe("resolveRenderMode", () => {
183
183
  expect(resolveRenderMode("candles", 28)).toMatchObject({ effectiveMode: "ohlc", fallbackMode: "ohlc" });
184
184
  expect(resolveRenderMode("candles", 39)).toMatchObject({ effectiveMode: "ohlc", fallbackMode: "ohlc" });
185
185
  expect(resolveRenderMode("candles", 40)).toMatchObject({ effectiveMode: "candles", fallbackMode: null });
186
+ expect(resolveRenderMode("hlc", 27)).toMatchObject({ effectiveMode: "line", fallbackMode: "line" });
187
+ expect(resolveRenderMode("hlc", 28)).toMatchObject({ effectiveMode: "hlc", fallbackMode: null });
186
188
  });
187
189
 
188
190
  test("forces compact charts back to area mode", () => {
@@ -192,7 +194,7 @@ describe("resolveRenderMode", () => {
192
194
  expect(projection.fallbackMode).toBeNull();
193
195
  });
194
196
 
195
- test("uses fewer projected buckets for candles and ohlc to preserve spacing", () => {
197
+ test("uses fewer projected buckets for candles and ohlc-style bars to preserve spacing", () => {
196
198
  const denseSeries = Array.from({ length: 100 }, (_, i) => ({
197
199
  date: new Date(2024, 0, i + 1),
198
200
  open: 100 + i,
@@ -204,6 +206,7 @@ describe("resolveRenderMode", () => {
204
206
 
205
207
  expect(projectChartData(denseSeries, 80, "candles", false).points).toHaveLength(34);
206
208
  expect(projectChartData(denseSeries, 80, "ohlc", false).points).toHaveLength(34);
209
+ expect(projectChartData(denseSeries, 80, "hlc", false).points).toHaveLength(34);
207
210
  expect(projectChartData(denseSeries, 80, "line", false).points).toHaveLength(80);
208
211
  });
209
212
 
@@ -468,7 +471,7 @@ describe("renderChart", () => {
468
471
  cellHeightPx,
469
472
  });
470
473
 
471
- expect(overlay.labelText).toBe("$214.03 ");
474
+ expect(overlay.labelText).toBe("$214.03");
472
475
  expect(overlay.topPercent).toBeCloseTo((cursorPixelY / (height * cellHeightPx - 1)) * 100, 5);
473
476
  });
474
477
 
@@ -528,6 +531,7 @@ describe("renderChart", () => {
528
531
  { mode: "line", width: 12, cursorX: 4 },
529
532
  { mode: "candles", width: 40, cursorX: 13 },
530
533
  { mode: "ohlc", width: 28, cursorX: 9 },
534
+ { mode: "hlc", width: 28, cursorX: 9 },
531
535
  ];
532
536
 
533
537
  for (const testCase of cases) {
@@ -553,6 +557,7 @@ describe("renderChart", () => {
553
557
  { mode: "area", width: 12, height: 6, cursorX: 5, cursorY: 4 },
554
558
  { mode: "candles", width: 40, height: 6, cursorX: 13, cursorY: 3 },
555
559
  { mode: "ohlc", width: 28, height: 6, cursorX: 9, cursorY: 1 },
560
+ { mode: "hlc", width: 28, height: 6, cursorX: 9, cursorY: 1 },
556
561
  ];
557
562
 
558
563
  for (const testCase of cases) {
@@ -638,12 +643,26 @@ describe("renderChart", () => {
638
643
  width: 28,
639
644
  height: 6,
640
645
  lines: [
641
- "⠁ ⠁ ⠁ ⢸ ⠁ ⠁ ⡄ ⠁ ⠁ ⠁",
642
- " ⢰ ⢸⠒⠂ ⠐⠒⡇ ",
643
- "⠁⢸⠒⠂ ⠁ ⠒⢺ ⠁ ⠁ ⡇ ⠁ ⠁ ⡆⠁",
644
- "⠤⢼ ⡀ ⡀ ⠸ ⡀ ⡀ ⡇ ⡀ ⡀ ⡏⠉",
645
- " ⠸ ⡧⠤ ⠠⠤⡇ ",
646
- "⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⡇ ⡀ ⡀ ⠃⡀",
646
+ "⠁ ⠁ ⠁ ⢸⣿ ⠁ ⠁ ⣤⡄ ⠁ ⠁ ⠁",
647
+ " ⢰⣶ ⢸⣿⠒⠂ ⠐⠒⣿⡇ ",
648
+ "⠁⢸⣿⠒⠂ ⠁ ⠒⢺⣿ ⠁ ⠁ ⣿⡇ ⠁ ⠁⣶⡆⠁",
649
+ "⠤⢼⣿⡀ ⡀ ⠸⠿ ⡀ ⡀ ⣿⡇ ⡀ ⡀⣿⡏⠉",
650
+ " ⠸⠿ ⣿⡧⠤ ⠠⠤⣿⡇ ",
651
+ "⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⣿⡇ ⡀ ⡀⠛⠃⡀",
652
+ ],
653
+ timeLabels: "Jan 2 3 4 Jan 5",
654
+ },
655
+ {
656
+ mode: "hlc",
657
+ width: 28,
658
+ height: 6,
659
+ lines: [
660
+ "⠁ ⠁ ⠁ ⢸⣿ ⠁ ⠁ ⣤⡄ ⠁ ⠁ ⠁",
661
+ " ⢰⣶ ⢸⣿⠒⠂ ⣿⡇ ",
662
+ "⠁⢸⣿⠒⠂ ⠁ ⢸⣿ ⠁ ⠁ ⣿⡇ ⠁ ⠁⣶⡆⠁",
663
+ "⡀⢸⣿⡀ ⡀ ⠸⠿ ⡀ ⡀ ⣿⡇ ⡀ ⡀⣿⡏⠉",
664
+ " ⠸⠿ ⣿⡧⠤ ⣿⡇ ",
665
+ "⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⣿⡇ ⡀ ⡀⠛⠃⡀",
647
666
  ],
648
667
  timeLabels: "Jan 2 3 4 Jan 5",
649
668
  },
@@ -7,12 +7,12 @@ import { useChartQueries } from "../../market-data/hooks";
7
7
  import { buildChartKey } from "../../market-data/selectors";
8
8
  import { useAppSelector, usePaneSettingValue } from "../../state/app-context";
9
9
  import { usePaneFooter, type PaneHint } from "../layout/pane-footer";
10
- import { blendHex, colors, getComparisonSeriesColor, priceColor } from "../../theme/colors";
10
+ import { blendHex, colors, getComparisonSeriesColor } from "../../theme/colors";
11
11
  import { useThemeColors } from "../../theme/theme-context";
12
12
  import type { BrokerContractRef } from "../../types/instrument";
13
13
  import type { PricePoint } from "../../types/financials";
14
14
  import { formatPercentRaw } from "../../utils/format";
15
- import { formatMarketPriceWithCurrency, formatSignedMarketPrice } from "../../utils/market-format";
15
+ import { formatMarketPriceWithCurrency } from "../../utils/market-format";
16
16
  import { getSharedMarketData } from "../../plugins/registry";
17
17
  import {
18
18
  applyComparisonZoomAroundAnchor,
@@ -30,7 +30,6 @@ import {
30
30
  import {
31
31
  buildVisibleDateWindow,
32
32
  clearActivePreset,
33
- formatVisibleSpanLabel,
34
33
  needsCanonicalPresetViewportReset,
35
34
  resolveChartBodyState,
36
35
  resolveCanonicalPresetViewport,
@@ -96,7 +95,7 @@ import {
96
95
  resolveNativeSurfaceVisibleRect,
97
96
  type NativeSurfaceRenderableNode,
98
97
  } from "./native/surface-visibility";
99
- import { formatDateShort, resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
98
+ import { resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
100
99
  import { TimeAxisLabel } from "./time-axis-label";
101
100
  import { PriceAxisLabels } from "./price-axis-labels";
102
101
 
@@ -445,6 +444,23 @@ function clipText(text: string, width: number): string {
445
444
  return `${text.slice(0, width - 3)}...`;
446
445
  }
447
446
 
447
+ function formatLegendSummary(
448
+ symbol: string,
449
+ rawValue: number | null,
450
+ baseValue: number | null,
451
+ currency: string,
452
+ priceRange: number | undefined,
453
+ ): string {
454
+ if (rawValue === null) return `${symbol} waiting`;
455
+ const price = formatMarketPriceWithCurrency(rawValue, currency, {
456
+ minimumFractionDigits: 2,
457
+ precisionOffset: 1,
458
+ priceRange,
459
+ });
460
+ if (baseValue === null || baseValue === 0) return `${symbol} ${price}`;
461
+ return `${symbol} ${price} ${formatPercentRaw(((rawValue - baseValue) / baseValue) * 100)}`;
462
+ }
463
+
448
464
  function getInitialComparisonMode(mode: string | undefined): ComparisonChartRenderMode {
449
465
  return mode === "line" ? "line" : "area";
450
466
  }
@@ -555,6 +571,7 @@ function ComparisonStockChartView({
555
571
  const mouseCrosshairDisabledRef = useRef(false);
556
572
  const cursorMotionKindRef = useRef<ChartCursorMotionKind>("discrete");
557
573
  const animationFrameRef = useRef<number | null>(null);
574
+ const lastAppliedStoredSelectionKeyRef = useRef<string | null>(null);
558
575
  const pendingCanonicalResetRef = useRef(1);
559
576
  const appliedCanonicalResetRef = useRef(0);
560
577
  const pendingExpansionRef = useRef<PendingExpansionAction>(null);
@@ -564,7 +581,7 @@ function ComparisonStockChartView({
564
581
  const axisRightPadding = 1;
565
582
  const minimumAxisWidth = axisMode === "percent" ? 5 : 4;
566
583
  const axisGap = axisSectionWidthBudget > 0 ? 1 : 0;
567
- const headerRows = 1;
584
+ const headerRows = 0;
568
585
  const controlRows = 2;
569
586
  const timeAxisRows = 1;
570
587
  const helpRows = 0;
@@ -591,13 +608,35 @@ function ComparisonStockChartView({
591
608
  ));
592
609
  };
593
610
 
594
- const capabilityKey = useMemo(() => symbolSources.map((source) => [
595
- source.symbol,
596
- source.exchange,
597
- source.brokerId ?? "",
598
- source.brokerInstanceId ?? "",
599
- source.instrument?.conId ?? "",
600
- ].join(":")).join("|"), [symbolSources]);
611
+ const marketDataProvider = getSharedMarketData();
612
+ const capabilityDescriptor = useMemo(() => {
613
+ const sources = symbolSources.map((source) => ({
614
+ symbol: source.symbol,
615
+ exchange: source.exchange,
616
+ brokerId: source.brokerId,
617
+ brokerInstanceId: source.brokerInstanceId,
618
+ instrument: source.instrument,
619
+ }));
620
+ return {
621
+ key: sources.map((source) => [
622
+ source.symbol,
623
+ source.exchange,
624
+ source.brokerId ?? "",
625
+ source.brokerInstanceId ?? "",
626
+ source.instrument?.conId ?? "",
627
+ source.instrument?.localSymbol ?? "",
628
+ source.instrument?.symbol ?? "",
629
+ ].join(":")).join("|"),
630
+ sources,
631
+ };
632
+ }, [symbolSources]);
633
+ const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
634
+ resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
635
+ ), [resolutionSupport]);
636
+ const selectionSupportMap = useMemo(
637
+ () => buildChartResolutionSupportMap(effectiveResolutionSupport),
638
+ [effectiveResolutionSupport],
639
+ );
601
640
 
602
641
  useEffect(() => {
603
642
  if (symbols.includes(viewState.selectedSymbol ?? "")) return;
@@ -608,21 +647,25 @@ function ComparisonStockChartView({
608
647
  }, [symbols, viewState.selectedSymbol]);
609
648
 
610
649
  useEffect(() => {
650
+ const storedSelectionKey = `${storedRangePreset}:${storedResolution}`;
651
+ if (lastAppliedStoredSelectionKeyRef.current === storedSelectionKey) return;
652
+
653
+ lastAppliedStoredSelectionKeyRef.current = storedSelectionKey;
611
654
  pendingCanonicalResetRef.current += 1;
612
655
  setViewState((current) => (
613
656
  storedResolution === "auto"
614
- ? resolveStoredChartSelection(current, storedRangePreset, storedResolution, supportMap)
657
+ ? resolveStoredChartSelection(current, storedRangePreset, storedResolution, selectionSupportMap)
615
658
  : resolvePresetSelection(
616
659
  {
617
660
  ...current,
618
661
  resolution: storedResolution,
619
662
  },
620
663
  storedRangePreset,
621
- getSupportMaxRange(supportMap, storedResolution),
664
+ getSupportMaxRange(selectionSupportMap, storedResolution),
622
665
  )
623
666
  ));
624
667
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
625
- }, [storedRangePreset, storedResolution, supportMap]);
668
+ }, [selectionSupportMap, storedRangePreset, storedResolution]);
626
669
 
627
670
  const commitDisplayCursor = (next: DisplayCursorState) => {
628
671
  displayCursorRef.current = next;
@@ -705,15 +748,15 @@ function ComparisonStockChartView({
705
748
  ), [nativeSurfaceManager]);
706
749
 
707
750
  useEffect(() => {
708
- const provider = getSharedMarketData();
709
- if ((!provider?.getChartResolutionSupport && !provider?.getChartResolutionCapabilities) || symbolSources.length === 0) {
751
+ const provider = marketDataProvider;
752
+ if ((!provider?.getChartResolutionSupport && !provider?.getChartResolutionCapabilities) || capabilityDescriptor.sources.length === 0) {
710
753
  setResolutionSupport(null);
711
754
  return;
712
755
  }
713
756
 
714
757
  let cancelled = false;
715
758
  setResolutionSupport(null);
716
- Promise.all(symbolSources.map(async (source) => {
759
+ Promise.all(capabilityDescriptor.sources.map(async (source) => {
717
760
  try {
718
761
  const support = provider.getChartResolutionSupport
719
762
  ? await provider.getChartResolutionSupport(
@@ -757,11 +800,7 @@ function ComparisonStockChartView({
757
800
  return () => {
758
801
  cancelled = true;
759
802
  };
760
- }, [capabilityKey, symbolSources]);
761
-
762
- const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
763
- resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
764
- ), [resolutionSupport]);
803
+ }, [capabilityDescriptor.key, marketDataProvider]);
765
804
  const availableManualResolutions = resolutionSupport?.map((entry) => entry.resolution) ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS;
766
805
  const effectiveResolution: ChartResolution = viewState.resolution !== "auto"
767
806
  && resolutionSupport !== null
@@ -873,7 +912,6 @@ function ComparisonStockChartView({
873
912
  ]);
874
913
  const axisSectionWidth = axisWidth + axisRightPadding;
875
914
  const chartWidth = Math.max(width - axisSectionWidth - axisGap, minChartWidth);
876
- const maxCursorX = chartWidth - 1;
877
915
  const seriesDates = useMemo(() => getUniqueSortedSeriesDates(series), [series]);
878
916
  const visibleDateWindow = useMemo(() => buildVisibleDateWindow(seriesDates, viewState.panOffset, viewState.zoomLevel), [seriesDates, viewState.panOffset, viewState.zoomLevel]);
879
917
  const activePreset = resolveVisibleActivePreset(seriesDates, {
@@ -1006,6 +1044,16 @@ function ComparisonStockChartView({
1006
1044
  ));
1007
1045
  };
1008
1046
 
1047
+ const selectSymbolByOffset = (offset: number) => {
1048
+ setViewState((current) => {
1049
+ const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1050
+ return {
1051
+ ...current,
1052
+ selectedSymbol: symbols[clamp(currentIndex + offset, 0, symbols.length - 1)] ?? current.selectedSymbol,
1053
+ };
1054
+ });
1055
+ };
1056
+
1009
1057
  useEffect(() => {
1010
1058
  const remapCursor = (cursor: DisplayCursorState) => buildDisplayCursorState(
1011
1059
  cursor.cellX,
@@ -1425,22 +1473,6 @@ function ComparisonStockChartView({
1425
1473
  case "t":
1426
1474
  onEditTickers?.();
1427
1475
  return;
1428
- case "h":
1429
- mouseCrosshairDisabledRef.current = false;
1430
- cursorMotionKindRef.current = "discrete";
1431
- setViewState((current) => ({
1432
- ...current,
1433
- cursorX: current.cursorX === null ? maxCursorX : Math.max(current.cursorX - 1, 0),
1434
- }));
1435
- return;
1436
- case "l":
1437
- mouseCrosshairDisabledRef.current = false;
1438
- cursorMotionKindRef.current = "discrete";
1439
- setViewState((current) => ({
1440
- ...current,
1441
- cursorX: current.cursorX === null ? 0 : Math.min(current.cursorX + 1, maxCursorX),
1442
- }));
1443
- return;
1444
1476
  case "escape":
1445
1477
  mouseCrosshairDisabledRef.current = true;
1446
1478
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
@@ -1451,42 +1483,20 @@ function ComparisonStockChartView({
1451
1483
  if (viewState.selectedSymbol) onOpenSymbol(viewState.selectedSymbol);
1452
1484
  return;
1453
1485
  case "left":
1454
- setViewState((current) => {
1455
- const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1456
- return {
1457
- ...current,
1458
- selectedSymbol: symbols[Math.max(currentIndex - 1, 0)] ?? current.selectedSymbol,
1459
- };
1460
- });
1486
+ case "h":
1487
+ selectSymbolByOffset(-1);
1461
1488
  return;
1462
1489
  case "right":
1463
- setViewState((current) => {
1464
- const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1465
- return {
1466
- ...current,
1467
- selectedSymbol: symbols[Math.min(currentIndex + 1, symbols.length - 1)] ?? current.selectedSymbol,
1468
- };
1469
- });
1490
+ case "l":
1491
+ selectSymbolByOffset(1);
1470
1492
  return;
1471
1493
  case "up":
1472
1494
  case "k":
1473
- setViewState((current) => {
1474
- const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1475
- return {
1476
- ...current,
1477
- selectedSymbol: symbols[Math.max(currentIndex - legendColumns, 0)] ?? current.selectedSymbol,
1478
- };
1479
- });
1495
+ selectSymbolByOffset(-legendColumns);
1480
1496
  return;
1481
1497
  case "down":
1482
1498
  case "j":
1483
- setViewState((current) => {
1484
- const currentIndex = Math.max(symbols.indexOf(current.selectedSymbol ?? symbols[0] ?? ""), 0);
1485
- return {
1486
- ...current,
1487
- selectedSymbol: symbols[Math.min(currentIndex + legendColumns, symbols.length - 1)] ?? current.selectedSymbol,
1488
- };
1489
- });
1499
+ selectSymbolByOffset(legendColumns);
1490
1500
  return;
1491
1501
  }
1492
1502
 
@@ -1629,18 +1639,9 @@ function ComparisonStockChartView({
1629
1639
  const hasChartData = series.some((entry) => entry.points.length > 0);
1630
1640
  const hasDisplayCursor = displayCursorX !== null && displayCursorY !== null;
1631
1641
  const cursorScene = hasDisplayCursor ? displayScene : staticScene;
1632
- const selectedSeries = hasChartData ? (cursorScene?.selectedSeries ?? staticResult.selectedSeries) : null;
1633
- const selectedPoint = hasChartData ? (cursorScene?.selectedPoint ?? staticResult.selectedPoint) : null;
1634
- const selectedRawValue = selectedPoint?.rawValue ?? selectedSeries?.latestRawValue ?? null;
1635
- const selectedBaseValue = selectedSeries?.baseValue ?? null;
1636
- const selectedChange = selectedRawValue !== null && selectedBaseValue !== null
1637
- ? selectedRawValue - selectedBaseValue
1638
- : null;
1639
- const selectedChangePct = selectedChange !== null && selectedBaseValue
1640
- ? (selectedChange / selectedBaseValue) * 100
1642
+ const legendActiveIndex = hasChartData
1643
+ ? cursorScene?.activeIdx ?? staticScene?.activeIdx ?? null
1641
1644
  : null;
1642
- const selectedCurrency = selectedSeries?.currency ?? "USD";
1643
- const displayDate = cursorScene?.activeDate ?? staticResult.activeDate;
1644
1645
  const visiblePriceRange = result.priceRange ?? staticResult.priceRange ?? undefined;
1645
1646
  const axisLabels = new Map(staticResult.axisLabels.map((entry) => [entry.row, entry.label]));
1646
1647
  const cursorRow = useCanvasChart ? cursorScene?.cursorRow ?? null : result.cursorRow;
@@ -1652,10 +1653,6 @@ function ComparisonStockChartView({
1652
1653
  visiblePriceRange,
1653
1654
  )
1654
1655
  : null;
1655
- const visibleLabel = formatVisibleSpanLabel({
1656
- start: visibleWindow.dates[0] ?? null,
1657
- end: visibleWindow.dates[visibleWindow.dates.length - 1] ?? null,
1658
- });
1659
1656
  const cursorTimeAxisColumn = hasDisplayCursor ? displayScene?.cursorColumn ?? null : null;
1660
1657
  const cursorTimeAxisDate = hasDisplayCursor ? displayScene?.activeDate ?? null : null;
1661
1658
 
@@ -1852,32 +1849,6 @@ function ComparisonStockChartView({
1852
1849
  flexGrow={1}
1853
1850
  onMouseScroll={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotScroll : undefined}
1854
1851
  >
1855
- <Box flexDirection="row" gap={2} height={1}>
1856
- <Text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1857
- {viewState.selectedSymbol ?? "Compare"} - {getChartResolutionLabel(effectiveResolution)}
1858
- </Text>
1859
- <Text fg={colors.textDim}>{visibleLabel}</Text>
1860
- <Text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1861
- {selectedRawValue !== null
1862
- ? formatMarketPriceWithCurrency(selectedRawValue, selectedCurrency, {
1863
- minimumFractionDigits: 2,
1864
- precisionOffset: 1,
1865
- priceRange: visiblePriceRange,
1866
- })
1867
- : "—"}
1868
- </Text>
1869
- <Text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1870
- {selectedChange !== null && selectedChangePct !== null
1871
- ? `${formatSignedMarketPrice(selectedChange, {
1872
- minimumFractionDigits: 2,
1873
- precisionOffset: 1,
1874
- priceRange: visiblePriceRange,
1875
- })} (${formatPercentRaw(selectedChangePct)})`
1876
- : "Waiting for data"}
1877
- </Text>
1878
- {displayDate && <Text fg={colors.textDim}>{formatDateShort(displayDate)}</Text>}
1879
- </Box>
1880
-
1881
1852
  <Box flexDirection="row" height={1}>
1882
1853
  <Box flexDirection="row" gap={1}>
1883
1854
  {TIME_RANGES.map((range, index) => (
@@ -1964,19 +1935,11 @@ function ComparisonStockChartView({
1964
1935
  {legendRow.map((symbol) => {
1965
1936
  const item = projection.series.find((entry) => entry.symbol === symbol) ?? null;
1966
1937
  const isSelected = viewState.selectedSymbol === symbol;
1967
- const latestRaw = item?.latestRawValue ?? null;
1968
- const latestChange = latestRaw !== null && item?.baseValue != null ? latestRaw - item.baseValue : null;
1969
- const latestChangePct = latestChange !== null && item?.baseValue
1970
- ? (latestChange / item.baseValue) * 100
1971
- : null;
1938
+ const activeRaw = legendActiveIndex !== null
1939
+ ? item?.points[legendActiveIndex]?.rawValue ?? null
1940
+ : item?.latestRawValue ?? null;
1972
1941
  const currency = item?.currency ?? "USD";
1973
- const summary = latestRaw !== null && latestChangePct !== null
1974
- ? `${symbol} ${formatMarketPriceWithCurrency(latestRaw, currency, {
1975
- minimumFractionDigits: 2,
1976
- precisionOffset: 1,
1977
- priceRange: visiblePriceRange,
1978
- })} ${formatPercentRaw(latestChangePct)}`
1979
- : `${symbol} waiting`;
1942
+ const summary = formatLegendSummary(symbol, activeRaw, item?.baseValue ?? null, currency, visiblePriceRange);
1980
1943
 
1981
1944
  return (
1982
1945
  <Box
@@ -247,16 +247,39 @@ function projectX(index: number, count: number, left: number, right: number): nu
247
247
  return lerp(left, right, index / (count - 1));
248
248
  }
249
249
 
250
+ function isOhlcLikeMode(mode: ChartRenderMode): boolean {
251
+ return mode === "ohlc" || mode === "hlc";
252
+ }
253
+
254
+ function isHighLowMode(mode: ChartRenderMode): boolean {
255
+ return mode === "candles" || isOhlcLikeMode(mode);
256
+ }
257
+
258
+ function getNativeBarMetrics(count: number, width: number, mode: Extract<ChartRenderMode, "candles" | "ohlc" | "hlc">) {
259
+ const spacing = width / Math.max(count, 1);
260
+ if (mode === "candles") {
261
+ return {
262
+ bodyWidth: clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2)),
263
+ tickLength: 0,
264
+ stemThickness: 1.2,
265
+ };
266
+ }
267
+
268
+ return {
269
+ bodyWidth: 0,
270
+ tickLength: clamp(spacing * 0.5, 3, Math.max(spacing * 0.62, 3)),
271
+ stemThickness: clamp(spacing * 0.12, 2, 3.2),
272
+ };
273
+ }
274
+
250
275
  function projectChartX(index: number, count: number, width: number, mode: ChartRenderMode): number {
251
- if (mode !== "candles" && mode !== "ohlc") {
276
+ if (!isHighLowMode(mode)) {
252
277
  return projectX(index, count, 0, Math.max(width - 1, 0));
253
278
  }
254
279
 
255
- const spacing = width / Math.max(count, 1);
256
- const bodyWidth = clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2));
257
- const tickLength = clamp(spacing * 0.34, 2, Math.max(spacing * 0.48, 2));
258
- const horizontalPad = mode === "ohlc"
259
- ? Math.ceil(Math.max(tickLength - 1, 0))
280
+ const { bodyWidth, tickLength, stemThickness } = getNativeBarMetrics(count, width, mode);
281
+ const horizontalPad = isOhlcLikeMode(mode)
282
+ ? Math.ceil(Math.max(tickLength - 1, stemThickness / 2, 0))
260
283
  : Math.ceil(bodyWidth / 2);
261
284
  return projectX(index, count, horizontalPad, Math.max(width - 1 - horizontalPad, horizontalPad));
262
285
  }
@@ -332,13 +355,11 @@ function drawCandles(
332
355
  scene: ChartScene,
333
356
  top: number,
334
357
  bottom: number,
335
- mode: Extract<ChartRenderMode, "candles" | "ohlc">,
358
+ mode: Extract<ChartRenderMode, "candles" | "ohlc" | "hlc">,
336
359
  ) {
337
- const spacing = width / Math.max(scene.points.length, 1);
338
- const bodyWidth = clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2));
339
- const tickLength = clamp(spacing * 0.34, 2, Math.max(spacing * 0.48, 2));
340
- const horizontalPad = mode === "ohlc"
341
- ? Math.ceil(Math.max(tickLength - 1, 0))
360
+ const { bodyWidth, tickLength, stemThickness } = getNativeBarMetrics(scene.points.length, width, mode);
361
+ const horizontalPad = isOhlcLikeMode(mode)
362
+ ? Math.ceil(Math.max(tickLength - 1, stemThickness / 2, 0))
342
363
  : Math.ceil(bodyWidth / 2);
343
364
  const plotLeft = horizontalPad;
344
365
  const plotRight = Math.max(width - 1 - horizontalPad, plotLeft);
@@ -355,10 +376,12 @@ function drawCandles(
355
376
  const wickColor = parseHex(isUp ? scene.colors.wickUp : scene.colors.wickDown, 0.92);
356
377
  const bodyColor = parseHex(isUp ? scene.colors.candleUp : scene.colors.candleDown, 1);
357
378
 
358
- if (mode === "ohlc") {
359
- drawLine(data, width, height, x, highY, x, lowY, wickColor, 1.2);
360
- drawLine(data, width, height, x - tickLength, openY, x, openY, bodyColor, 1.4);
361
- drawLine(data, width, height, x, closeY, x + tickLength, closeY, bodyColor, 1.4);
379
+ if (isOhlcLikeMode(mode)) {
380
+ drawLine(data, width, height, x, highY, x, lowY, wickColor, stemThickness);
381
+ if (mode === "ohlc") {
382
+ drawLine(data, width, height, x - tickLength, openY, x, openY, bodyColor, Math.max(stemThickness, 1.4));
383
+ }
384
+ drawLine(data, width, height, x, closeY, x + tickLength, closeY, bodyColor, Math.max(stemThickness, 1.4));
362
385
  continue;
363
386
  }
364
387
 
@@ -395,7 +418,7 @@ function drawVolume(
395
418
  if (heightRatio <= 0) continue;
396
419
  const x = projectX(index, scene.points.length, 0, width - 1);
397
420
  const barTop = lerp(bottom, top, heightRatio);
398
- const isUp = scene.mode === "candles" || scene.mode === "ohlc"
421
+ const isUp = isHighLowMode(scene.mode)
399
422
  ? point.close >= point.open
400
423
  : index === 0 || point.close >= scene.points[index - 1]!.close;
401
424
  const color = parseHex(isUp ? scene.colors.volumeUp : scene.colors.volumeDown, 0.6);
@@ -486,6 +509,9 @@ export function renderNativeChartBase(scene: ChartScene, pixelWidth: number, pix
486
509
  case "ohlc":
487
510
  drawCandles(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom, "ohlc");
488
511
  break;
512
+ case "hlc":
513
+ drawCandles(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom, "hlc");
514
+ break;
489
515
  }
490
516
 
491
517
  drawIndicatorOverlays(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
@@ -25,7 +25,6 @@ function clamp(value: number, min: number, max: number): number {
25
25
 
26
26
  export function buildCursorPriceAxisOverlay({
27
27
  axisWidth,
28
- axisSectionWidth,
29
28
  height,
30
29
  cursorPixelY,
31
30
  cursorLabel,
@@ -40,7 +39,7 @@ export function buildCursorPriceAxisOverlay({
40
39
  }): CursorPriceAxisOverlay {
41
40
  const labelText = cursorLabel === null
42
41
  ? null
43
- : formatAxisCell(cursorLabel, axisWidth).padEnd(axisSectionWidth);
42
+ : formatAxisCell(cursorLabel, axisWidth).trimStart();
44
43
  const pixelHeight = Math.max(height * cellHeightPx, 1);
45
44
  let topPercent: number | null = null;
46
45
 
@@ -73,6 +72,20 @@ export function PriceAxisLabels({
73
72
  cellHeightPx,
74
73
  }), [axisSectionWidth, axisWidth, cellHeightPx, cursorLabel, cursorPixelY, height]);
75
74
  const usePixelOverlay = fractionalViewport && overlay.labelText !== null && overlay.topPercent !== null;
75
+ const axisPaddingWidth = Math.max(0, axisSectionWidth - axisWidth);
76
+ const axisLabelJustify = fractionalViewport ? "flex-start" : "flex-end";
77
+ const renderAxisLabel = (label: string | null, fg: string) => (
78
+ fractionalViewport ? (
79
+ <Box flexDirection="row" width={axisSectionWidth} height={1}>
80
+ <Box flexDirection="row" width={axisWidth} justifyContent={axisLabelJustify} overflow="hidden">
81
+ {label ? <Text fg={fg}>{formatAxisCell(label, axisWidth).trimStart()}</Text> : null}
82
+ </Box>
83
+ {axisPaddingWidth > 0 ? <Box width={axisPaddingWidth} /> : null}
84
+ </Box>
85
+ ) : (
86
+ <Text fg={fg}>{formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}</Text>
87
+ )
88
+ );
76
89
 
77
90
  return (
78
91
  <Box
@@ -86,16 +99,16 @@ export function PriceAxisLabels({
86
99
  const isCursorRow = !usePixelOverlay && cursorLabel !== null && cursorRow === row;
87
100
  const label = isCursorRow ? cursorLabel : (axisLabels.get(row) ?? null);
88
101
  return (
89
- <Text key={row} fg={isCursorRow ? cursorColor : colors.textDim}>
90
- {formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}
91
- </Text>
102
+ <Box key={row} height={1}>
103
+ {renderAxisLabel(label, isCursorRow ? cursorColor : colors.textDim)}
104
+ </Box>
92
105
  );
93
106
  })}
94
107
  {usePixelOverlay ? (
95
- <Text
108
+ <Box
96
109
  width={axisSectionWidth}
97
- fg={cursorColor}
98
110
  bg={colors.bg}
111
+ flexDirection="row"
99
112
  style={{
100
113
  position: "absolute",
101
114
  left: 0,
@@ -106,8 +119,11 @@ export function PriceAxisLabels({
106
119
  zIndex: 1,
107
120
  }}
108
121
  >
109
- {overlay.labelText}
110
- </Text>
122
+ <Box flexDirection="row" width={axisWidth} justifyContent={axisLabelJustify} overflow="hidden">
123
+ <Text fg={cursorColor}>{overlay.labelText}</Text>
124
+ </Box>
125
+ {axisPaddingWidth > 0 ? <Box width={axisPaddingWidth} /> : null}
126
+ </Box>
111
127
  ) : null}
112
128
  </Box>
113
129
  );
@@ -382,7 +382,7 @@ describe("StockChart renderer switching", () => {
382
382
  const manager = getNativeSurfaceManager(testSetup.renderer as never) as unknown as {
383
383
  surfaces: Map<string, { snapshot: { rect: { height: number } } }>;
384
384
  };
385
- expect(manager.surfaces.get("chart-surface:ticker-detail:test:full:base")?.snapshot.rect.height).toBe(11);
385
+ expect(manager.surfaces.get("chart-surface:ticker-detail:test:full:base")?.snapshot.rect.height).toBe(12);
386
386
  });
387
387
 
388
388
  test("moves candle OHLC summary into the pane footer", async () => {