gloomberb 0.4.1 → 0.5.1

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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  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 +292 -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 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  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 +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  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 +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  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 +24 -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/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -7,7 +7,8 @@ import { useAppState, usePaneSettingValue } from "../../state/app-context";
7
7
  import { blendHex, colors, getComparisonSeriesColor, priceColor } from "../../theme/colors";
8
8
  import type { BrokerContractRef } from "../../types/instrument";
9
9
  import type { PricePoint } from "../../types/financials";
10
- import { formatCurrency, formatPercentRaw } from "../../utils/format";
10
+ import { formatPercentRaw } from "../../utils/format";
11
+ import { formatMarketPriceWithCurrency, formatSignedMarketPrice } from "../../utils/market-format";
11
12
  import { getSharedDataProvider } from "../../plugins/registry";
12
13
  import {
13
14
  applyComparisonZoomAroundAnchor,
@@ -16,15 +17,24 @@ import {
16
17
  projectComparisonChartData,
17
18
  } from "./comparison-chart-data";
18
19
  import { usePersistChartControlSelection } from "./chart-pane-settings";
20
+ import {
21
+ applyBufferedPanExpansion,
22
+ consumeScrollPanMovement,
23
+ getKeyboardPanCellCount,
24
+ resolveDragPanOffset,
25
+ } from "./chart-scroll";
19
26
  import {
20
27
  buildVisibleDateWindow,
21
28
  clearActivePreset,
22
29
  formatVisibleSpanLabel,
23
- getCanonicalZoomLevel,
24
- isCanonicalPresetViewport,
30
+ needsCanonicalPresetViewportReset,
25
31
  resolveChartBodyState,
32
+ resolveCanonicalPresetViewport,
26
33
  resolvePresetSelection,
34
+ resolvePresetRangeViewport,
27
35
  resolveResolutionSelection,
36
+ resolveStoredChartSelection,
37
+ resolveVisibleActivePreset,
28
38
  } from "./chart-controller";
29
39
  import {
30
40
  buildChartResolutionSupportMap,
@@ -43,10 +53,11 @@ import {
43
53
  type ChartResolutionSupport,
44
54
  type ManualChartResolution,
45
55
  } from "./chart-resolution";
46
- import { RIGHT_EDGE_ANCHOR_RATIO } from "./chart-viewport";
56
+ import { RIGHT_EDGE_ANCHOR_RATIO, resolveAnchoredChartZoom } from "./chart-viewport";
47
57
  import {
48
58
  buildComparisonChartScene,
49
59
  formatComparisonAxisValue,
60
+ formatComparisonCursorAxisValue,
50
61
  renderComparisonChart,
51
62
  } from "./comparison-chart-renderer";
52
63
  import {
@@ -79,7 +90,8 @@ import { ensureKittySupport, getCachedKittySupport } from "./native/kitty-suppor
79
90
  import { resolveChartRendererState } from "./native/renderer-selection";
80
91
  import { getNativeSurfaceManager } from "./native/surface-manager";
81
92
  import { syncCachedNativeSurface } from "./native/surface-sync";
82
- import { formatDateShort, type StyledContent } from "./chart-renderer";
93
+ import { formatAxisCell, formatDateShort, resolveChartAxisWidth, type StyledContent } from "./chart-renderer";
94
+ import { ChartControlHint } from "./chart-control-hint";
83
95
 
84
96
  const MODE_CHIPS: Record<ComparisonChartRenderMode, string> = {
85
97
  area: "A",
@@ -127,6 +139,8 @@ interface ChartMouseEvent {
127
139
  y: number;
128
140
  pixelX?: number;
129
141
  pixelY?: number;
142
+ stopPropagation?: () => void;
143
+ preventDefault?: () => void;
130
144
  modifiers: {
131
145
  shift: boolean;
132
146
  alt: boolean;
@@ -407,11 +421,6 @@ function buildBlankPlotLines(width: number, height: number): string[] {
407
421
  return Array.from({ length: height }, () => " ".repeat(width));
408
422
  }
409
423
 
410
- function formatAxisCell(label: string | null, width: number): string {
411
- if (!label) return " ".repeat(width);
412
- return label.length >= width ? label.slice(0, width) : label.padStart(width);
413
- }
414
-
415
424
  function buildComparisonNativeBitmapKey(
416
425
  symbolCount: number,
417
426
  projection: ReturnType<typeof projectComparisonChartData>,
@@ -561,6 +570,7 @@ function ComparisonStockChartView({
561
570
  selectedSymbol: symbols[0] ?? null,
562
571
  });
563
572
  const [resolutionSupport, setResolutionSupport] = useState<ChartResolutionSupport[] | null>(null);
573
+ const supportMap = useMemo(() => buildChartResolutionSupportMap(resolutionSupport ?? []), [resolutionSupport]);
564
574
  const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
565
575
  const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
566
576
  const plotRef = useRef<BoxRenderable | null>(null);
@@ -577,9 +587,12 @@ function ComparisonStockChartView({
577
587
  const pendingCanonicalResetRef = useRef(1);
578
588
  const appliedCanonicalResetRef = useRef(0);
579
589
  const pendingExpansionRef = useRef<PendingExpansionAction>(null);
590
+ const scrollPanCellRemainderRef = useRef(0);
580
591
 
581
- const axisWidth = 11;
582
- const axisGap = 1;
592
+ const axisSectionWidthBudget = 11;
593
+ const axisRightPadding = 1;
594
+ const minimumAxisWidth = axisMode === "percent" ? 5 : 4;
595
+ const axisGap = axisSectionWidthBudget > 0 ? 1 : 0;
583
596
  const headerRows = 1;
584
597
  const controlRows = 2;
585
598
  const timeAxisRows = 1;
@@ -589,9 +602,9 @@ function ComparisonStockChartView({
589
602
  const legendRows = legendNeededRows > 0
590
603
  ? Math.min(4, Math.max(Math.min(height - (headerRows + controlRows + timeAxisRows + helpRows + 4), legendNeededRows), 1))
591
604
  : 0;
592
- const chartWidth = Math.max(width - axisWidth - axisGap, 20);
593
605
  const chartHeight = Math.max(height - headerRows - controlRows - timeAxisRows - helpRows - legendRows, 4);
594
- const maxCursorX = chartWidth - 1;
606
+ const minChartWidth = 20;
607
+ const measurementChartWidth = Math.max(width - axisSectionWidthBudget - axisGap, minChartWidth);
595
608
  const legendItemWidth = Math.max(Math.floor((width - Math.max(legendColumns - 1, 0)) / legendColumns), 20);
596
609
  const chartColors = useMemo(() => ({
597
610
  bgColor: colors.bg,
@@ -625,16 +638,20 @@ function ComparisonStockChartView({
625
638
 
626
639
  useEffect(() => {
627
640
  pendingCanonicalResetRef.current += 1;
628
- setViewState((current) => resolvePresetSelection(
629
- {
630
- ...current,
631
- resolution: storedResolution,
632
- },
633
- storedRangePreset,
634
- storedResolution === "auto" ? null : getSupportMaxRange(resolutionSupport ?? [], storedResolution),
641
+ setViewState((current) => (
642
+ storedResolution === "auto"
643
+ ? resolveStoredChartSelection(current, storedRangePreset, storedResolution, supportMap)
644
+ : resolvePresetSelection(
645
+ {
646
+ ...current,
647
+ resolution: storedResolution,
648
+ },
649
+ storedRangePreset,
650
+ getSupportMaxRange(supportMap, storedResolution),
651
+ )
635
652
  ));
636
653
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
637
- }, [resolutionSupport, storedRangePreset, storedResolution]);
654
+ }, [storedRangePreset, storedResolution, supportMap]);
638
655
 
639
656
  const commitDisplayCursor = (next: DisplayCursorState) => {
640
657
  displayCursorRef.current = next;
@@ -754,7 +771,11 @@ function ComparisonStockChartView({
754
771
  }
755
772
  })).then((supportSets) => {
756
773
  if (!cancelled) {
757
- setResolutionSupport(supportSets.some((support) => support === null) ? null : intersectChartResolutionSupport(supportSets));
774
+ setResolutionSupport(
775
+ supportSets.some((support) => support === null)
776
+ ? null
777
+ : intersectChartResolutionSupport(supportSets.filter((support): support is ChartResolutionSupport[] => support !== null)),
778
+ );
758
779
  }
759
780
  }).catch(() => {
760
781
  if (!cancelled) {
@@ -767,7 +788,6 @@ function ComparisonStockChartView({
767
788
  };
768
789
  }, [capabilityKey, symbolSources]);
769
790
 
770
- const supportMap = useMemo(() => buildChartResolutionSupportMap(resolutionSupport ?? []), [resolutionSupport]);
771
791
  const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
772
792
  resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
773
793
  ), [resolutionSupport]);
@@ -819,24 +839,93 @@ function ComparisonStockChartView({
819
839
  points: history,
820
840
  };
821
841
  }), [chartEntries, chartRequests, colors.bg, symbolSources]);
842
+ const axisWidth = useMemo(() => {
843
+ const measureAxisWidth = (targetWidth: number) => {
844
+ const measuredProjection = projectComparisonChartData(series, targetWidth, viewState, axisMode);
845
+ const measuredResult = renderComparisonChart(measuredProjection, {
846
+ width: targetWidth,
847
+ height: chartHeight,
848
+ cursorX: null,
849
+ cursorY: null,
850
+ selectedSymbol: viewState.selectedSymbol,
851
+ colors: chartColors,
852
+ });
853
+ const measuredScene = buildComparisonChartScene(measuredProjection, {
854
+ width: targetWidth,
855
+ height: chartHeight,
856
+ cursorX: null,
857
+ cursorY: null,
858
+ selectedSymbol: viewState.selectedSymbol,
859
+ colors: chartColors,
860
+ });
861
+ const cursorSamples = measuredScene
862
+ ? [
863
+ formatComparisonCursorAxisValue(
864
+ measuredScene.min,
865
+ measuredProjection.effectiveAxisMode,
866
+ measuredResult.priceRange ?? undefined,
867
+ ),
868
+ formatComparisonCursorAxisValue(
869
+ measuredScene.max,
870
+ measuredProjection.effectiveAxisMode,
871
+ measuredResult.priceRange ?? undefined,
872
+ ),
873
+ ]
874
+ : [];
875
+
876
+ return resolveChartAxisWidth(
877
+ [...measuredResult.axisLabels.map((entry) => entry.label), ...cursorSamples],
878
+ minimumAxisWidth,
879
+ Math.max(axisSectionWidthBudget - axisRightPadding, minimumAxisWidth),
880
+ );
881
+ };
882
+
883
+ const firstPassWidth = measureAxisWidth(measurementChartWidth);
884
+ const refinedChartWidth = Math.max(width - firstPassWidth - axisRightPadding - axisGap, minChartWidth);
885
+ return refinedChartWidth === measurementChartWidth ? firstPassWidth : measureAxisWidth(refinedChartWidth);
886
+ }, [
887
+ axisGap,
888
+ axisMode,
889
+ axisRightPadding,
890
+ axisSectionWidthBudget,
891
+ chartColors,
892
+ chartHeight,
893
+ measurementChartWidth,
894
+ minChartWidth,
895
+ minimumAxisWidth,
896
+ series,
897
+ viewState.panOffset,
898
+ viewState.renderMode,
899
+ viewState.selectedSymbol,
900
+ viewState.zoomLevel,
901
+ width,
902
+ ]);
903
+ const axisSectionWidth = axisWidth + axisRightPadding;
904
+ const chartWidth = Math.max(width - axisSectionWidth - axisGap, minChartWidth);
905
+ const maxCursorX = chartWidth - 1;
822
906
  const seriesDates = useMemo(() => getUniqueSortedSeriesDates(series), [series]);
823
907
  const visibleDateWindow = useMemo(() => buildVisibleDateWindow(seriesDates, viewState.panOffset, viewState.zoomLevel), [seriesDates, viewState.panOffset, viewState.zoomLevel]);
824
- const activePreset = isCanonicalPresetViewport(seriesDates, {
908
+ const activePreset = resolveVisibleActivePreset(seriesDates, {
909
+ presetRange: viewState.presetRange,
825
910
  activePreset: viewState.activePreset,
826
911
  panOffset: viewState.panOffset,
827
912
  zoomLevel: viewState.zoomLevel,
828
913
  resolution: effectiveResolution,
829
- }) ? viewState.activePreset : null;
914
+ });
830
915
 
831
916
  useEffect(() => {
832
917
  if (seriesDates.length === 0) return;
833
- if (appliedCanonicalResetRef.current < pendingCanonicalResetRef.current) {
834
- const canonicalZoom = getCanonicalZoomLevel(seriesDates, viewState.presetRange);
835
- appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
918
+ const hasPendingCanonicalReset = appliedCanonicalResetRef.current < pendingCanonicalResetRef.current;
919
+ const shouldReconcileActivePreset = !hasPendingCanonicalReset
920
+ && needsCanonicalPresetViewportReset(seriesDates, viewState);
921
+ if (hasPendingCanonicalReset || shouldReconcileActivePreset) {
922
+ if (hasPendingCanonicalReset) {
923
+ appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
924
+ }
836
925
  setViewState((current) => (
837
- current.zoomLevel === canonicalZoom && current.panOffset === 0
838
- ? current
839
- : { ...current, zoomLevel: canonicalZoom, panOffset: 0, cursorX: null, cursorY: null }
926
+ hasPendingCanonicalReset
927
+ ? resolvePresetRangeViewport(current, seriesDates)
928
+ : resolveCanonicalPresetViewport(current, seriesDates)
840
929
  ));
841
930
  return;
842
931
  }
@@ -856,7 +945,15 @@ function ComparisonStockChartView({
856
945
  panOffset: clamp(pendingExpansion.targetPanOffset, 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
857
946
  };
858
947
  });
859
- }, [chartWidth, series, seriesDates, viewState.presetRange]);
948
+ }, [
949
+ chartWidth,
950
+ series,
951
+ seriesDates,
952
+ viewState.activePreset,
953
+ viewState.panOffset,
954
+ viewState.presetRange,
955
+ viewState.zoomLevel,
956
+ ]);
860
957
 
861
958
  useEffect(() => {
862
959
  const refreshSupport = () => setKittySupport(getCachedKittySupport(renderer));
@@ -889,12 +986,7 @@ function ComparisonStockChartView({
889
986
  : clampTimeRangeToMaxRange(nextCandidate, supportMap.get(effectiveResolution) ?? viewState.bufferRange);
890
987
  if (nextBufferRange === viewState.bufferRange) return false;
891
988
  pendingExpansionRef.current = action;
892
- setViewState((current) => ({
893
- ...clearActivePreset(current),
894
- bufferRange: nextBufferRange,
895
- cursorX: null,
896
- cursorY: null,
897
- }));
989
+ setViewState((current) => applyBufferedPanExpansion(current, nextBufferRange));
898
990
  return true;
899
991
  };
900
992
 
@@ -1222,6 +1314,7 @@ function ComparisonStockChartView({
1222
1314
 
1223
1315
  switch (event.name) {
1224
1316
  case "=":
1317
+ case "+":
1225
1318
  setViewState((current) => applyComparisonZoomAroundAnchor(
1226
1319
  clearActivePreset(current),
1227
1320
  current.zoomLevel * 1.5,
@@ -1230,6 +1323,7 @@ function ComparisonStockChartView({
1230
1323
  ));
1231
1324
  return;
1232
1325
  case "-":
1326
+ case "_":
1233
1327
  if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1234
1328
  kind: "zoom-out",
1235
1329
  targetVisibleCount: Math.round(seriesDates.length * 1.5),
@@ -1255,24 +1349,28 @@ function ComparisonStockChartView({
1255
1349
  cursorY: null,
1256
1350
  }));
1257
1351
  return;
1258
- case "a":
1352
+ case "a": {
1353
+ const panStep = getKeyboardPanCellCount(chartWidth);
1259
1354
  if (viewState.panOffset >= getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth) && expandBufferRange({
1260
1355
  kind: "pan-left",
1261
- targetPanOffset: viewState.panOffset + Math.max(Math.floor(chartWidth / 10), 1),
1356
+ targetPanOffset: viewState.panOffset + panStep,
1262
1357
  })) {
1263
1358
  return;
1264
1359
  }
1265
1360
  setViewState((current) => ({
1266
1361
  ...clearActivePreset(current),
1267
- panOffset: clamp(current.panOffset + Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
1362
+ panOffset: clamp(current.panOffset + panStep, 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
1268
1363
  }));
1269
1364
  return;
1270
- case "d":
1365
+ }
1366
+ case "d": {
1367
+ const panStep = getKeyboardPanCellCount(chartWidth);
1271
1368
  setViewState((current) => ({
1272
1369
  ...clearActivePreset(current),
1273
- panOffset: clamp(current.panOffset - Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
1370
+ panOffset: clamp(current.panOffset - panStep, 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
1274
1371
  }));
1275
1372
  return;
1373
+ }
1276
1374
  case "m":
1277
1375
  setViewState((current) => ({
1278
1376
  ...current,
@@ -1415,21 +1513,31 @@ function ComparisonStockChartView({
1415
1513
  if (!dragRef.current) return;
1416
1514
 
1417
1515
  const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
1418
- const pointDelta = Math.round((deltaCells / Math.max(chartWidth, 1)) * Math.max(visibleWindow.dates.length, 1));
1419
- const nextPan = clamp(
1420
- dragRef.current.startPanOffset - pointDelta,
1421
- 0,
1422
- Math.max(visibleWindow.totalDates - visibleWindow.dates.length, 0),
1516
+ const nextPan = resolveDragPanOffset(
1517
+ dragRef.current.startPanOffset,
1518
+ deltaCells,
1519
+ chartWidth,
1520
+ visibleWindow.dates.length,
1521
+ visibleWindow.totalDates - visibleWindow.dates.length,
1423
1522
  );
1424
1523
  setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1425
1524
  };
1426
1525
 
1427
1526
  const handlePlotScroll = (event: ChartMouseEvent) => {
1527
+ event.stopPropagation?.();
1528
+ event.preventDefault?.();
1428
1529
  const direction = event.scroll?.direction;
1429
1530
  if (!direction) return;
1531
+ const scrollPan = consumeScrollPanMovement(
1532
+ chartWidth,
1533
+ event.scroll?.delta,
1534
+ direction,
1535
+ scrollPanCellRemainderRef.current,
1536
+ );
1537
+ scrollPanCellRemainderRef.current = scrollPan.remainder;
1538
+ const scrollPanCells = scrollPan.cells;
1430
1539
 
1431
1540
  const localPointer = getLocalPlotPointer(event, plotRef.current, renderer);
1432
- const anchorRatio = localPointer ? localPointer.cellX / Math.max(chartWidth - 1, 1) : 0.5;
1433
1541
 
1434
1542
  if (localPointer) {
1435
1543
  const selectionCursor = resolveSelectionCursor(localPointer, projection.dates.length, chartWidth);
@@ -1447,65 +1555,23 @@ function ComparisonStockChartView({
1447
1555
  commitSelectionCursor(selectionCursor);
1448
1556
  }
1449
1557
 
1450
- if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1451
- const shiftDirection = direction === "up" ? 1 : -1;
1452
- const targetPanOffset = viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1);
1453
- if (shiftDirection > 0 && viewState.panOffset >= getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth) && expandBufferRange({
1454
- kind: "pan-left",
1455
- targetPanOffset,
1456
- })) {
1457
- return;
1458
- }
1459
- const nextPan = clamp(
1460
- targetPanOffset,
1461
- 0,
1462
- getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth),
1463
- );
1464
- setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1465
- return;
1466
- }
1558
+ if (scrollPanCells === 0) return;
1467
1559
 
1468
- switch (direction) {
1469
- case "up":
1470
- setViewState((current) => applyComparisonZoomAroundAnchor(clearActivePreset(current), current.zoomLevel * 1.18, anchorRatio, series));
1471
- return;
1472
- case "down":
1473
- if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1474
- kind: "zoom-out",
1475
- targetVisibleCount: Math.round(seriesDates.length * 1.18),
1476
- anchorRatio,
1477
- })) {
1478
- return;
1479
- }
1480
- setViewState((current) => applyComparisonZoomAroundAnchor(clearActivePreset(current), current.zoomLevel / 1.18, anchorRatio, series));
1481
- return;
1482
- case "left":
1483
- if (viewState.panOffset >= getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth) && expandBufferRange({
1484
- kind: "pan-left",
1485
- targetPanOffset: viewState.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1486
- })) {
1487
- return;
1488
- }
1489
- setViewState((current) => ({
1490
- ...clearActivePreset(current),
1491
- panOffset: clamp(
1492
- current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1493
- 0,
1494
- getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth),
1495
- ),
1496
- }));
1497
- return;
1498
- case "right":
1499
- setViewState((current) => ({
1500
- ...clearActivePreset(current),
1501
- panOffset: clamp(
1502
- current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1503
- 0,
1504
- getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth),
1505
- ),
1506
- }));
1507
- return;
1560
+ const targetPanOffset = viewState.panOffset + scrollPanCells;
1561
+ if (scrollPanCells > 0 && viewState.panOffset >= getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth) && expandBufferRange({
1562
+ kind: "pan-left",
1563
+ targetPanOffset,
1564
+ })) {
1565
+ return;
1508
1566
  }
1567
+ setViewState((current) => ({
1568
+ ...clearActivePreset(current),
1569
+ panOffset: clamp(
1570
+ current.panOffset + scrollPanCells,
1571
+ 0,
1572
+ getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth),
1573
+ ),
1574
+ }));
1509
1575
  };
1510
1576
 
1511
1577
  if (symbols.length === 0) {
@@ -1525,9 +1591,14 @@ function ComparisonStockChartView({
1525
1591
  : null;
1526
1592
  const selectedCurrency = selectedSeries?.currency ?? "USD";
1527
1593
  const displayDate = result.activeDate ?? staticResult.activeDate;
1594
+ const visiblePriceRange = result.priceRange ?? staticResult.priceRange ?? undefined;
1528
1595
  const axisLabels = new Map(staticResult.axisLabels.map((entry) => [entry.row, entry.label]));
1529
1596
  const cursorAxisLabel = result.cursorRow !== null && result.crosshairValue !== null
1530
- ? formatComparisonAxisValue(result.crosshairValue, projection.effectiveAxisMode)
1597
+ ? formatComparisonCursorAxisValue(
1598
+ result.crosshairValue,
1599
+ projection.effectiveAxisMode,
1600
+ visiblePriceRange,
1601
+ )
1531
1602
  : null;
1532
1603
  const visibleLabel = formatVisibleSpanLabel({
1533
1604
  start: visibleWindow.dates[0] ?? null,
@@ -1538,7 +1609,7 @@ function ComparisonStockChartView({
1538
1609
  ? blankPlotLines
1539
1610
  : result.lines;
1540
1611
  const plotContent = plotLines.map((line, index) => (
1541
- <text key={index} content={line} />
1612
+ <text key={index} content={line as unknown as string} />
1542
1613
  ));
1543
1614
 
1544
1615
  const plotBox = (
@@ -1556,7 +1627,6 @@ function ComparisonStockChartView({
1556
1627
  onMouseOut={hasChartData && !isBlockingBody && !bodyMessage ? () => {
1557
1628
  dragRef.current = null;
1558
1629
  } : undefined}
1559
- onMouseScroll={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotScroll : undefined}
1560
1630
  >
1561
1631
  {isBlockingBody
1562
1632
  ? (
@@ -1575,13 +1645,13 @@ function ComparisonStockChartView({
1575
1645
  );
1576
1646
 
1577
1647
  const axisBox = (
1578
- <box width={axisWidth} height={chartHeight} flexDirection="column">
1648
+ <box width={axisSectionWidth} height={chartHeight} flexDirection="column">
1579
1649
  {Array.from({ length: chartHeight }, (_, row) => {
1580
1650
  const isCursorRow = cursorAxisLabel !== null && result.cursorRow === row;
1581
1651
  const label = isCursorRow ? cursorAxisLabel : (axisLabels.get(row) ?? null);
1582
1652
  return (
1583
1653
  <text key={row} fg={isCursorRow ? chartColors.crosshairColor : colors.textDim}>
1584
- {formatAxisCell(label, axisWidth)}
1654
+ {formatAxisCell(label, axisWidth).padEnd(axisSectionWidth)}
1585
1655
  </text>
1586
1656
  );
1587
1657
  })}
@@ -1593,18 +1663,32 @@ function ComparisonStockChartView({
1593
1663
  ));
1594
1664
 
1595
1665
  return (
1596
- <box flexDirection="column" flexGrow={1}>
1666
+ <box
1667
+ flexDirection="column"
1668
+ flexGrow={1}
1669
+ onMouseScroll={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotScroll : undefined}
1670
+ >
1597
1671
  <box flexDirection="row" gap={2} height={1}>
1598
1672
  <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1599
1673
  {viewState.selectedSymbol ?? "Compare"} - {getChartResolutionLabel(effectiveResolution)}
1600
1674
  </text>
1601
1675
  <text fg={colors.textDim}>{visibleLabel}</text>
1602
1676
  <text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1603
- {selectedRawValue !== null ? formatCurrency(selectedRawValue, selectedCurrency) : "—"}
1677
+ {selectedRawValue !== null
1678
+ ? formatMarketPriceWithCurrency(selectedRawValue, selectedCurrency, {
1679
+ minimumFractionDigits: 2,
1680
+ precisionOffset: 1,
1681
+ priceRange: visiblePriceRange,
1682
+ })
1683
+ : "—"}
1604
1684
  </text>
1605
1685
  <text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1606
1686
  {selectedChange !== null && selectedChangePct !== null
1607
- ? `${selectedChange >= 0 ? "+" : ""}${selectedChange.toFixed(2)} (${formatPercentRaw(selectedChangePct)})`
1687
+ ? `${formatSignedMarketPrice(selectedChange, {
1688
+ minimumFractionDigits: 2,
1689
+ precisionOffset: 1,
1690
+ priceRange: visiblePriceRange,
1691
+ })} (${formatPercentRaw(selectedChangePct)})`
1608
1692
  : "Waiting for data"}
1609
1693
  </text>
1610
1694
  {displayDate && <text fg={colors.textDim}>{formatDateShort(displayDate)}</text>}
@@ -1641,9 +1725,6 @@ function ComparisonStockChartView({
1641
1725
  {getChartResolutionLabel(resolution)}
1642
1726
  </text>
1643
1727
  ))}
1644
- {viewState.zoomLevel !== 1 && (
1645
- <text fg={colors.textDim}>zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1646
- )}
1647
1728
  {isUpdating && (
1648
1729
  <text fg={colors.textDim}>updating</text>
1649
1730
  )}
@@ -1669,7 +1750,12 @@ function ComparisonStockChartView({
1669
1750
  </box>
1670
1751
  </box>
1671
1752
 
1672
- <box flexDirection="row" height={chartHeight} gap={axisGap}>
1753
+ <box
1754
+ flexDirection="row"
1755
+ height={chartHeight}
1756
+ gap={axisGap}
1757
+ onMouseScroll={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotScroll : undefined}
1758
+ >
1673
1759
  {plotBox}
1674
1760
  {axisBox}
1675
1761
  </box>
@@ -1693,7 +1779,11 @@ function ComparisonStockChartView({
1693
1779
  : null;
1694
1780
  const currency = item?.currency ?? "USD";
1695
1781
  const summary = latestRaw !== null && latestChangePct !== null
1696
- ? `${symbol} ${formatCurrency(latestRaw, currency)} ${formatPercentRaw(latestChangePct)}`
1782
+ ? `${symbol} ${formatMarketPriceWithCurrency(latestRaw, currency, {
1783
+ minimumFractionDigits: 2,
1784
+ precisionOffset: 1,
1785
+ priceRange: visiblePriceRange,
1786
+ })} ${formatPercentRaw(latestChangePct)}`
1697
1787
  : `${symbol} waiting`;
1698
1788
 
1699
1789
  return (
@@ -1720,9 +1810,14 @@ function ComparisonStockChartView({
1720
1810
  )}
1721
1811
 
1722
1812
  <box height={1}>
1723
- <text fg={colors.textMuted}>
1724
- mouse hover inspect drag pan wheel zoom ⇧wheel pan h/l cursor arrows legend Enter open 1-7 presets r res m mode 0 reset
1725
- </text>
1813
+ <box flexDirection="row" gap={1}>
1814
+ <ChartControlHint hotkey="m" label="ode" />
1815
+ <ChartControlHint hotkey="r" label="es" />
1816
+ <ChartControlHint hotkey="+/-" label="zoom" />
1817
+ <ChartControlHint hotkey="0" label="reset" />
1818
+ {width >= 72 && <ChartControlHint hotkey="1-7" label="range" />}
1819
+ {width >= 88 && <ChartControlHint hotkey="up/down" label="legend" />}
1820
+ </box>
1726
1821
  </box>
1727
1822
  </box>
1728
1823
  );
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { computeBollingerBands } from "./bands";
3
+
4
+ describe("computeBollingerBands", () => {
5
+ const closes = [10, 12, 11, 13, 15, 14, 13, 12, 14, 16];
6
+
7
+ it("upper > middle > lower for all points with period 5", () => {
8
+ const result = computeBollingerBands(closes, 5, 2);
9
+ expect(result.upper.length).toBeGreaterThan(0);
10
+ expect(result.middle.length).toBeGreaterThan(0);
11
+ expect(result.lower.length).toBeGreaterThan(0);
12
+ for (let i = 0; i < result.middle.length; i++) {
13
+ expect(result.upper[i].value).toBeGreaterThan(result.middle[i].value);
14
+ expect(result.middle[i].value).toBeGreaterThan(result.lower[i].value);
15
+ }
16
+ });
17
+
18
+ it("indices match across all three bands", () => {
19
+ const result = computeBollingerBands(closes, 5, 2);
20
+ for (let i = 0; i < result.middle.length; i++) {
21
+ expect(result.upper[i].index).toBe(result.middle[i].index);
22
+ expect(result.lower[i].index).toBe(result.middle[i].index);
23
+ }
24
+ });
25
+
26
+ it("first point starts at index period-1", () => {
27
+ const result = computeBollingerBands(closes, 5, 2);
28
+ expect(result.middle[0].index).toBe(4);
29
+ });
30
+
31
+ it("returns empty for insufficient data", () => {
32
+ const result = computeBollingerBands([1, 2, 3], 5, 2);
33
+ expect(result.upper.length).toBe(0);
34
+ expect(result.middle.length).toBe(0);
35
+ expect(result.lower.length).toBe(0);
36
+ });
37
+
38
+ it("returns empty for empty input", () => {
39
+ const result = computeBollingerBands([], 20, 2);
40
+ expect(result.upper.length).toBe(0);
41
+ expect(result.middle.length).toBe(0);
42
+ expect(result.lower.length).toBe(0);
43
+ });
44
+
45
+ it("middle values match SMA", () => {
46
+ // For period 5: first SMA = (10+12+11+13+15)/5 = 61/5 = 12.2
47
+ const result = computeBollingerBands(closes, 5, 2);
48
+ expect(result.middle[0].value).toBeCloseTo(12.2);
49
+ });
50
+ });
@@ -0,0 +1,29 @@
1
+ import type { BollingerResult } from "./types";
2
+ import { computeSMA } from "./moving-averages";
3
+
4
+ export function computeBollingerBands(
5
+ closes: number[],
6
+ period = 20,
7
+ stdDev = 2
8
+ ): BollingerResult {
9
+ const empty: BollingerResult = { upper: [], middle: [], lower: [] };
10
+ const sma = computeSMA(closes, period);
11
+ if (sma.length === 0) return empty;
12
+
13
+ for (const midPoint of sma) {
14
+ const end = midPoint.index;
15
+ const start = end - period + 1;
16
+ const slice = closes.slice(start, end + 1);
17
+
18
+ const mean = midPoint.value;
19
+ const variance = slice.reduce((sum, v) => sum + (v - mean) ** 2, 0) / period;
20
+ const sd = Math.sqrt(variance);
21
+
22
+ const upper = mean + stdDev * sd;
23
+ empty.upper.push({ index: midPoint.index, value: upper });
24
+ empty.middle.push({ index: midPoint.index, value: mean });
25
+ empty.lower.push({ index: midPoint.index, value: mean - stdDev * sd });
26
+ }
27
+
28
+ return empty;
29
+ }