gloomberb 0.4.1 → 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 (207) 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/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import { RGBA } from "@opentui/core";
2
- import type { ChartAxisMode, ChartColors, Pixel, PixelBuffer, ChartRenderMode } from "./chart-types";
2
+ import type { ChartAxisMode, ChartColors, ChartIndicatorOverlays, Pixel, PixelBuffer, ChartRenderMode } from "./chart-types";
3
3
  import type { ProjectedChartPoint } from "./chart-data";
4
- import { formatCurrency } from "../../utils/format";
4
+ import { formatCompactMarketPriceWithCurrency, formatMarketPrice, resolveAssetDisplayKind } from "../../utils/market-format";
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Styled output types (unchanged public API)
@@ -26,6 +26,7 @@ export interface StyledContent {
26
26
  const LAYER_GRID = 0;
27
27
  const LAYER_FILL = 1;
28
28
  const LAYER_DATA = 2;
29
+ const LAYER_OVERLAY = 2;
29
30
  const LAYER_CROSSHAIR = 3;
30
31
 
31
32
  // ---------------------------------------------------------------------------
@@ -574,8 +575,21 @@ export function bufferToBrailleLines(buf: PixelBuffer, bgColor: string): StyledC
574
575
  // Formatting helpers
575
576
  // ---------------------------------------------------------------------------
576
577
 
577
- export function formatPrice(value: number): string {
578
- return formatPriceWithCurrency(value);
578
+ export function formatPrice(
579
+ value: number,
580
+ assetCategory?: string,
581
+ priceRange?: number,
582
+ precisionOffset = 0,
583
+ minimumFractionDigits = 0,
584
+ fixedFractionDigits?: number,
585
+ ): string {
586
+ return formatMarketPrice(value, {
587
+ assetCategory,
588
+ fixedFractionDigits,
589
+ minimumFractionDigits,
590
+ precisionOffset,
591
+ priceRange,
592
+ });
579
593
  }
580
594
 
581
595
  function formatPercentAxisValue(value: number): string {
@@ -585,39 +599,125 @@ function formatPercentAxisValue(value: number): string {
585
599
  return `${prefix}${value.toFixed(decimals)}%`;
586
600
  }
587
601
 
588
- const currencySymbols = new Map<string, string>();
602
+ export function formatPriceWithCurrency(
603
+ value: number,
604
+ currency = "USD",
605
+ assetCategory?: string,
606
+ priceRange?: number,
607
+ precisionOffset = 0,
608
+ minimumFractionDigits = 0,
609
+ fixedFractionDigits?: number,
610
+ ): string {
611
+ return formatCompactMarketPriceWithCurrency(value, currency, {
612
+ assetCategory,
613
+ fixedFractionDigits,
614
+ minimumFractionDigits,
615
+ precisionOffset,
616
+ priceRange,
617
+ });
618
+ }
589
619
 
590
- function getCurrencySymbol(currency: string): string {
591
- const cached = currencySymbols.get(currency);
592
- if (cached) return cached;
620
+ function hasDistinctAxisLabels(labels: string[]): boolean {
621
+ return new Set(labels).size === labels.length;
622
+ }
593
623
 
594
- const formatter = new Intl.NumberFormat("en-US", {
595
- style: "currency",
596
- currency,
597
- currencyDisplay: "symbol",
598
- minimumFractionDigits: 0,
599
- maximumFractionDigits: 0,
600
- });
601
- const symbol = formatter.formatToParts(0).find((part) => part.type === "currency")?.value ?? currency;
602
- currencySymbols.set(currency, symbol);
603
- return symbol;
624
+ function getAxisFractionDigitFloor(assetCategory: string | undefined, priceRange: number | undefined): number {
625
+ if (priceRange === undefined || !Number.isFinite(priceRange) || priceRange <= 0) return 0;
626
+
627
+ const kind = resolveAssetDisplayKind({ assetCategory });
628
+ const visibleStep = priceRange / 3;
629
+ if (!Number.isFinite(visibleStep) || visibleStep <= 0) return 0;
630
+
631
+ const offset = kind === "cash" ? 0 : 1;
632
+ return Math.max(0, Math.ceil(-Math.log10(visibleStep)) + offset);
604
633
  }
605
634
 
606
- export function formatPriceWithCurrency(value: number, currency = "USD"): string {
607
- const abs = Math.abs(value);
608
- const sign = value < 0 ? "-" : "";
609
- const symbol = getCurrencySymbol(currency);
635
+ export function resolveAxisFractionDigits(
636
+ prices: number[],
637
+ formatLabel: (value: number, fixedFractionDigits: number) => string,
638
+ minimumFractionDigits = 0,
639
+ maxFractionDigits = 8,
640
+ ): number {
641
+ for (let fixedFractionDigits = minimumFractionDigits; fixedFractionDigits <= maxFractionDigits; fixedFractionDigits += 1) {
642
+ const labels = prices.map((value) => formatLabel(value, fixedFractionDigits));
643
+ if (hasDistinctAxisLabels(labels)) return fixedFractionDigits;
644
+ }
645
+
646
+ return maxFractionDigits;
647
+ }
610
648
 
611
- if (abs >= 1e6) return `${sign}${symbol}${(abs / 1e6).toFixed(1)}M`;
612
- if (abs >= 1e3 && abs < 1e5) return `${sign}${symbol}${(abs / 1e3).toFixed(1)}K`;
613
- return formatCurrency(value, currency);
649
+ export function resolveChartAxisWidth(
650
+ labels: Array<string | null | undefined>,
651
+ minimumWidth: number,
652
+ maximumWidth: number,
653
+ ): number {
654
+ if (maximumWidth <= 0) return 0;
655
+
656
+ const longestLabel = labels.reduce((maxWidth, label) => (
657
+ Math.max(maxWidth, label?.length ?? 0)
658
+ ), 0);
659
+
660
+ return Math.min(Math.max(longestLabel, minimumWidth), maximumWidth);
661
+ }
662
+
663
+ export function formatAxisCell(label: string | null, width: number): string {
664
+ if (width <= 0) return "";
665
+ if (!label) return " ".repeat(width);
666
+ return label.length >= width ? label.slice(0, width) : label.padStart(width);
614
667
  }
615
668
 
616
- export function formatAxisValue(value: number, axisMode: ChartAxisMode, basePrice: number, currency = "USD"): string {
669
+ export function formatAxisValue(
670
+ value: number,
671
+ axisMode: ChartAxisMode,
672
+ basePrice: number,
673
+ currency = "USD",
674
+ assetCategory?: string,
675
+ priceRange?: number,
676
+ fixedFractionDigits?: number,
677
+ ): string {
617
678
  if (axisMode === "percent" && basePrice !== 0) {
618
679
  return formatPercentAxisValue(((value - basePrice) / basePrice) * 100);
619
680
  }
620
- return formatPriceWithCurrency(value, currency);
681
+ return formatPriceWithCurrency(value, currency, assetCategory, priceRange, 0, 0, fixedFractionDigits);
682
+ }
683
+
684
+ function getCursorAxisMinimumFractionDigits(value: number, assetCategory?: string): number {
685
+ const kind = resolveAssetDisplayKind({ assetCategory });
686
+
687
+ switch (kind) {
688
+ case "cash":
689
+ return 4;
690
+ case "crypto":
691
+ return Math.abs(value) >= 1 ? 4 : 6;
692
+ case "equity":
693
+ case "contract":
694
+ return Math.abs(value) >= 1 ? 2 : 4;
695
+ case "other":
696
+ default:
697
+ return Math.abs(value) >= 1 ? 2 : 4;
698
+ }
699
+ }
700
+
701
+ export function formatCursorAxisValue(
702
+ value: number,
703
+ axisMode: ChartAxisMode,
704
+ basePrice: number,
705
+ currency = "USD",
706
+ assetCategory?: string,
707
+ priceRange?: number,
708
+ ): string {
709
+ if (axisMode === "percent" && basePrice !== 0) {
710
+ return formatPercentAxisValue(((value - basePrice) / basePrice) * 100);
711
+ }
712
+
713
+ return formatPriceWithCurrency(
714
+ value,
715
+ currency,
716
+ assetCategory,
717
+ priceRange,
718
+ 2,
719
+ getCursorAxisMinimumFractionDigits(value, assetCategory),
720
+ );
621
721
  }
622
722
 
623
723
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -916,8 +1016,10 @@ export interface RenderChartOptions {
916
1016
  mode: ChartRenderMode;
917
1017
  axisMode?: ChartAxisMode;
918
1018
  currency?: string;
1019
+ assetCategory?: string;
919
1020
  colors: ResolvedChartPalette;
920
1021
  timeAxisDates?: Array<Date | string | number>;
1022
+ indicators?: ChartIndicatorOverlays | null;
921
1023
  }
922
1024
 
923
1025
  export interface ChartScene {
@@ -929,6 +1031,7 @@ export interface ChartScene {
929
1031
  chartRows: number;
930
1032
  mode: ChartRenderMode;
931
1033
  colors: ResolvedChartPalette;
1034
+ indicators: ChartIndicatorOverlays | null;
932
1035
  min: number;
933
1036
  max: number;
934
1037
  activeIdx: number;
@@ -958,6 +1061,8 @@ export interface RenderChartResult {
958
1061
  changePctAtCursor: number | null;
959
1062
  cursorColumn: number | null;
960
1063
  cursorRow: number | null;
1064
+ axisFractionDigits: number | null;
1065
+ priceRange: number | null;
961
1066
  /** Raw pixel buffer for GPU/Kitty rendering path */
962
1067
  pixelBuffer: PixelBuffer | null;
963
1068
  }
@@ -988,18 +1093,42 @@ export function getActivePointIndex(
988
1093
  return bestIndex;
989
1094
  }
990
1095
 
1096
+ function getIndicatorOverlayValues(indicators: ChartIndicatorOverlays | null | undefined): number[] {
1097
+ if (!indicators) return [];
1098
+
1099
+ const values: number[] = [];
1100
+ for (const line of indicators.smaLines) {
1101
+ values.push(...line.points.map((point) => point.value));
1102
+ }
1103
+ for (const line of indicators.emaLines) {
1104
+ values.push(...line.points.map((point) => point.value));
1105
+ }
1106
+ if (indicators.bollinger) {
1107
+ values.push(
1108
+ ...indicators.bollinger.upper.map((point) => point.value),
1109
+ ...indicators.bollinger.middle.map((point) => point.value),
1110
+ ...indicators.bollinger.lower.map((point) => point.value),
1111
+ );
1112
+ }
1113
+
1114
+ return values.filter((value) => Number.isFinite(value));
1115
+ }
1116
+
991
1117
  export function buildChartScene(
992
1118
  points: ProjectedChartPoint[],
993
1119
  opts: RenderChartOptions,
994
1120
  ): ChartScene | null {
995
1121
  if (points.length === 0) return null;
996
1122
 
997
- const min = opts.mode === "candles" || opts.mode === "ohlc"
1123
+ const dataMin = opts.mode === "candles" || opts.mode === "ohlc"
998
1124
  ? Math.min(...points.map((point) => point.low))
999
1125
  : Math.min(...points.map((point) => point.close));
1000
- const max = opts.mode === "candles" || opts.mode === "ohlc"
1126
+ const dataMax = opts.mode === "candles" || opts.mode === "ohlc"
1001
1127
  ? Math.max(...points.map((point) => point.high))
1002
1128
  : Math.max(...points.map((point) => point.close));
1129
+ const indicatorValues = getIndicatorOverlayValues(opts.indicators);
1130
+ const min = indicatorValues.length > 0 ? Math.min(dataMin, ...indicatorValues) : dataMin;
1131
+ const max = indicatorValues.length > 0 ? Math.max(dataMax, ...indicatorValues) : dataMax;
1003
1132
  const activeIdx = getActivePointIndex(points.length, opts.width, opts.cursorX, opts.mode);
1004
1133
  const activePoint = points[activeIdx]!;
1005
1134
  const range = max - min || 1;
@@ -1039,6 +1168,7 @@ export function buildChartScene(
1039
1168
  chartRows,
1040
1169
  mode: opts.mode,
1041
1170
  colors: opts.colors,
1171
+ indicators: opts.indicators ?? null,
1042
1172
  min,
1043
1173
  max,
1044
1174
  activeIdx,
@@ -1057,6 +1187,41 @@ export function buildChartScene(
1057
1187
  };
1058
1188
  }
1059
1189
 
1190
+ function drawIndicatorOverlays(
1191
+ buf: PixelBuffer,
1192
+ indicators: ChartIndicatorOverlays,
1193
+ pointCount: number,
1194
+ dotTop: number,
1195
+ dotBottom: number,
1196
+ min: number,
1197
+ max: number,
1198
+ mode: ChartRenderMode,
1199
+ ): void {
1200
+ const drawOverlay = (overlayPoints: { index: number; value: number }[], color: string) => {
1201
+ for (let i = 0; i < overlayPoints.length - 1; i++) {
1202
+ const p0 = overlayPoints[i]!;
1203
+ const p1 = overlayPoints[i + 1]!;
1204
+ const x0 = getDotX(p0.index, pointCount, buf.width, mode);
1205
+ const y0 = getScaledY(p0.value, min, max, dotTop, dotBottom);
1206
+ const x1 = getDotX(p1.index, pointCount, buf.width, mode);
1207
+ const y1 = getScaledY(p1.value, min, max, dotTop, dotBottom);
1208
+ drawLine(buf, x0, y0, x1, y1, color, LAYER_OVERLAY);
1209
+ }
1210
+ };
1211
+
1212
+ for (const sma of indicators.smaLines) {
1213
+ drawOverlay(sma.points, sma.color);
1214
+ }
1215
+ for (const ema of indicators.emaLines) {
1216
+ drawOverlay(ema.points, ema.color);
1217
+ }
1218
+ if (indicators.bollinger) {
1219
+ drawOverlay(indicators.bollinger.upper, indicators.bollinger.color);
1220
+ drawOverlay(indicators.bollinger.middle, indicators.bollinger.color);
1221
+ drawOverlay(indicators.bollinger.lower, indicators.bollinger.color);
1222
+ }
1223
+ }
1224
+
1060
1225
  export function renderChart(
1061
1226
  points: ProjectedChartPoint[],
1062
1227
  opts: RenderChartOptions,
@@ -1075,6 +1240,8 @@ export function renderChart(
1075
1240
  changePctAtCursor: null,
1076
1241
  cursorColumn: null,
1077
1242
  cursorRow: null,
1243
+ axisFractionDigits: null,
1244
+ priceRange: null,
1078
1245
  pixelBuffer: null,
1079
1246
  };
1080
1247
  }
@@ -1082,6 +1249,7 @@ export function renderChart(
1082
1249
  const { width, colors: palette, mode } = opts;
1083
1250
  const axisMode = opts.axisMode ?? "price";
1084
1251
  const currency = opts.currency ?? "USD";
1252
+ const assetCategory = opts.assetCategory;
1085
1253
 
1086
1254
  // Braille resolution: 2 dot-columns per terminal column, 4 dot-rows per terminal row
1087
1255
  const dotWidth = width * 2;
@@ -1094,8 +1262,24 @@ export function renderChart(
1094
1262
 
1095
1263
  const buf = createPixelBuffer(dotWidth, totalDotH);
1096
1264
  const { min, max } = scene;
1265
+ const priceRange = max - min;
1097
1266
 
1098
1267
  const gridLines = computeGridLines(min, max, 0, chartDotBottom, 3);
1268
+ const axisFractionDigits = axisMode === "price"
1269
+ ? resolveAxisFractionDigits(
1270
+ gridLines.map((line) => line.price),
1271
+ (price, fixedFractionDigits) => formatPriceWithCurrency(
1272
+ price,
1273
+ currency,
1274
+ assetCategory,
1275
+ priceRange,
1276
+ 0,
1277
+ 0,
1278
+ fixedFractionDigits,
1279
+ ),
1280
+ getAxisFractionDigitFloor(assetCategory, priceRange),
1281
+ )
1282
+ : null;
1099
1283
  drawGridLines(buf, gridLines.map((line) => line.y), palette.gridColor);
1100
1284
 
1101
1285
  switch (mode) {
@@ -1126,6 +1310,10 @@ export function renderChart(
1126
1310
  );
1127
1311
  }
1128
1312
 
1313
+ if (opts.indicators) {
1314
+ drawIndicatorOverlays(buf, opts.indicators, points.length, 0, chartDotBottom, min, max, mode);
1315
+ }
1316
+
1129
1317
  // Cursor mapping stays in terminal-column space
1130
1318
  const { activePoint, priceAtCursor, crosshairPrice, dateAtCursor, changeAtCursor, changePctAtCursor } = scene;
1131
1319
 
@@ -1137,7 +1325,7 @@ export function renderChart(
1137
1325
  for (const line of gridLines) {
1138
1326
  axisLabelsByRow.set(
1139
1327
  Math.min(Math.floor(line.y / 4), Math.max(chartTermRows - 1, 0)),
1140
- formatAxisValue(line.price, axisMode, points[0]!.close, currency),
1328
+ formatAxisValue(line.price, axisMode, points[0]!.close, currency, assetCategory, priceRange, axisFractionDigits ?? undefined),
1141
1329
  );
1142
1330
  }
1143
1331
 
@@ -1155,6 +1343,8 @@ export function renderChart(
1155
1343
  changePctAtCursor,
1156
1344
  cursorColumn: scene.cursorColumn,
1157
1345
  cursorRow: scene.cursorRow,
1346
+ axisFractionDigits,
1347
+ priceRange,
1158
1348
  pixelBuffer: buf,
1159
1349
  };
1160
1350
  }
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ CHART_RESOLUTION_STEP_MS,
3
4
  clampTimeRangeForResolution,
4
5
  getActiveRangePreset,
6
+ getBestSupportedResolutionForDateWindow,
7
+ getBestSupportedResolutionForVisibleWindow,
5
8
  getPresetResolution,
6
9
  intersectChartResolutions,
7
10
  isRangePresetSupported,
@@ -53,4 +56,68 @@ describe("chart-resolution", () => {
53
56
  expect(normalizeChartResolution("bogus", "1d")).toBe("1d");
54
57
  expect(sortChartResolutions(["1wk", "auto", "15m", "1d"])).toEqual(["auto", "15m", "1d", "1wk"]);
55
58
  });
59
+
60
+ test("picks the best supported resolution for the current visible date window", () => {
61
+ const support = [
62
+ { resolution: "1m", maxRange: "1D" },
63
+ { resolution: "5m", maxRange: "1W" },
64
+ { resolution: "15m", maxRange: "1M" },
65
+ { resolution: "1h", maxRange: "3M" },
66
+ { resolution: "1d", maxRange: "5Y" },
67
+ { resolution: "1wk", maxRange: "ALL" },
68
+ ] as const;
69
+
70
+ expect(getBestSupportedResolutionForDateWindow({
71
+ start: new Date("2026-01-08T10:00:00Z"),
72
+ end: new Date("2026-01-08T16:00:00Z"),
73
+ }, support)).toBe("1m");
74
+
75
+ expect(getBestSupportedResolutionForDateWindow({
76
+ start: new Date("2026-01-01T00:00:00Z"),
77
+ end: new Date("2026-01-08T00:00:00Z"),
78
+ }, support)).toBe("5m");
79
+
80
+ expect(getBestSupportedResolutionForDateWindow({
81
+ start: new Date("2021-01-01T00:00:00Z"),
82
+ end: new Date("2026-01-01T00:00:00Z"),
83
+ }, support)).toBe("1wk");
84
+ });
85
+
86
+ test("picks a denser supported resolution as the visible window narrows", () => {
87
+ const support = [
88
+ { resolution: "1m", maxRange: "1D" },
89
+ { resolution: "5m", maxRange: "1W" },
90
+ { resolution: "15m", maxRange: "1M" },
91
+ { resolution: "1h", maxRange: "3M" },
92
+ { resolution: "1d", maxRange: "5Y" },
93
+ { resolution: "1wk", maxRange: "ALL" },
94
+ ] as const;
95
+
96
+ expect(getBestSupportedResolutionForVisibleWindow({
97
+ start: new Date("2021-01-01T00:00:00Z"),
98
+ end: new Date("2026-01-01T00:00:00Z"),
99
+ }, support, 100)).toBe("1wk");
100
+
101
+ expect(getBestSupportedResolutionForVisibleWindow({
102
+ start: new Date("2025-01-01T00:00:00Z"),
103
+ end: new Date("2026-01-01T00:00:00Z"),
104
+ }, support, 100)).toBe("1d");
105
+
106
+ expect(getBestSupportedResolutionForVisibleWindow({
107
+ start: new Date("2025-12-01T00:00:00Z"),
108
+ end: new Date("2026-01-01T00:00:00Z"),
109
+ }, support, 100)).toBe("1h");
110
+
111
+ expect(getBestSupportedResolutionForVisibleWindow({
112
+ start: new Date("2025-12-25T00:00:00Z"),
113
+ end: new Date("2026-01-01T00:00:00Z"),
114
+ }, support, 100)).toBe("15m");
115
+
116
+ expect(getBestSupportedResolutionForVisibleWindow({
117
+ start: new Date("2026-01-01T00:00:00Z"),
118
+ end: new Date("2026-01-02T00:00:00Z"),
119
+ }, support, 100)).toBe("1m");
120
+
121
+ expect(CHART_RESOLUTION_STEP_MS["1h"]).toBe(60 * 60_000);
122
+ });
56
123
  });
@@ -58,11 +58,34 @@ export const RANGE_PRELOAD_BUFFER: Record<TimeRange, TimeRange> = {
58
58
  };
59
59
 
60
60
  export const DEFAULT_TICKER_CHART_RANGE_PRESET: TimeRange = "5Y";
61
- export const DEFAULT_TICKER_CHART_RESOLUTION: ManualChartResolution = "1wk";
61
+ export const DEFAULT_TICKER_CHART_RESOLUTION: ChartResolution = "auto";
62
62
  export const DEFAULT_COMPARISON_CHART_RANGE_PRESET: TimeRange = "1Y";
63
63
  export const DEFAULT_COMPARISON_CHART_RESOLUTION: ManualChartResolution = "1d";
64
64
 
65
65
  export const DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS: ManualChartResolution[] = ["1m", "5m", "15m", "1h", "1d", "1wk", "1mo"];
66
+ export const CHART_RESOLUTION_STEP_MS: Record<ManualChartResolution, number> = {
67
+ "1m": 60_000,
68
+ "5m": 5 * 60_000,
69
+ "15m": 15 * 60_000,
70
+ "30m": 30 * 60_000,
71
+ "45m": 45 * 60_000,
72
+ "1h": 60 * 60_000,
73
+ "1d": 24 * 60 * 60_000,
74
+ "1wk": 7 * 24 * 60 * 60_000,
75
+ "1mo": 30 * 24 * 60 * 60_000,
76
+ };
77
+
78
+ const CHART_RESOLUTION_POINTS_PER_DAY: Record<ManualChartResolution, number> = {
79
+ "1m": 390,
80
+ "5m": 78,
81
+ "15m": 26,
82
+ "30m": 13,
83
+ "45m": 9,
84
+ "1h": 7,
85
+ "1d": 1,
86
+ "1wk": 1 / 5,
87
+ "1mo": 1 / 21,
88
+ };
66
89
 
67
90
  const TIME_RANGE_INDEX = new Map(TIME_RANGE_ORDER.map((range, index) => [range, index]));
68
91
  const RANGE_PRESETS_BY_RESOLUTION = TIME_RANGE_ORDER.reduce<Record<ManualChartResolution, TimeRange[]>>((acc, range) => {
@@ -140,7 +163,7 @@ export function normalizeManualChartResolutions(resolutions: readonly string[]):
140
163
 
141
164
  export function intersectChartResolutions(resolutionSets: Array<readonly string[]>): ManualChartResolution[] {
142
165
  if (resolutionSets.length === 0) return [];
143
- const [first, ...rest] = resolutionSets.map((set) => new Set(normalizeManualChartResolutions(set)));
166
+ const [first = new Set<ManualChartResolution>(), ...rest] = resolutionSets.map((set) => new Set(normalizeManualChartResolutions(set)));
144
167
  return sortChartResolutions(
145
168
  [...first].filter((resolution) => rest.every((set) => set.has(resolution))),
146
169
  );
@@ -194,8 +217,8 @@ export function getSupportMaxRange(
194
217
  support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
195
218
  resolution: ManualChartResolution,
196
219
  ): TimeRange | null {
197
- if (support instanceof Map) {
198
- return support.get(resolution) ?? null;
220
+ if (!Array.isArray(support)) {
221
+ return (support as ReadonlyMap<ManualChartResolution, TimeRange>).get(resolution) ?? null;
199
222
  }
200
223
  return normalizeChartResolutionSupport(support).find((entry) => entry.resolution === resolution)?.maxRange ?? null;
201
224
  }
@@ -301,6 +324,53 @@ export function getBestSupportedResolutionForPreset(
301
324
  return supportedResolutions[supportedResolutions.length - 1] ?? null;
302
325
  }
303
326
 
327
+ export function getBestSupportedResolutionForDateWindow(
328
+ window: { start: Date | null; end: Date | null } | null,
329
+ support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
330
+ ): ManualChartResolution | null {
331
+ if (!window?.start || !window.end) return null;
332
+ const range = getTimeRangeForDateWindow(window);
333
+ return getBestSupportedResolutionForPreset(range, support);
334
+ }
335
+
336
+ export function getTimeRangeForDateWindow(
337
+ window: { start: Date | null; end: Date | null } | null,
338
+ ): TimeRange {
339
+ if (!window?.start || !window.end) return "ALL";
340
+ return TIME_RANGE_ORDER.find((candidate) => isDateWindowWithinTimeRange(window.start!, window.end!, candidate)) ?? "ALL";
341
+ }
342
+
343
+ export function getBestSupportedResolutionForVisibleWindow(
344
+ window: { start: Date | null; end: Date | null } | null,
345
+ support: readonly ChartResolutionSupport[] | ReadonlyMap<ManualChartResolution, TimeRange>,
346
+ targetPointCount: number,
347
+ ): ManualChartResolution | null {
348
+ if (!window?.start || !window.end) return null;
349
+
350
+ const spanMs = Math.max(window.end.getTime() - window.start.getTime(), 0);
351
+ const dayCount = Math.max(spanMs / CHART_RESOLUTION_STEP_MS["1d"], 1 / 24);
352
+ const supportedResolutions = sortChartResolutions(
353
+ CHART_RESOLUTION_ORDER
354
+ .filter((resolution): resolution is ManualChartResolution => resolution !== "auto")
355
+ .filter((resolution) => {
356
+ const maxRange = getSupportMaxRange(support, resolution);
357
+ return maxRange !== null && isDateWindowWithinTimeRange(window.start!, window.end!, maxRange);
358
+ }),
359
+ );
360
+
361
+ if (supportedResolutions.length === 0) return null;
362
+
363
+ const minimumPointTarget = Math.max(targetPointCount, 1);
364
+ for (const resolution of [...supportedResolutions].reverse()) {
365
+ const estimatedPointCount = Math.max(dayCount * CHART_RESOLUTION_POINTS_PER_DAY[resolution], 1);
366
+ if (estimatedPointCount >= minimumPointTarget) {
367
+ return resolution;
368
+ }
369
+ }
370
+
371
+ return supportedResolutions[0] ?? null;
372
+ }
373
+
304
374
  export function getNextFallbackResolution(
305
375
  range: TimeRange,
306
376
  currentResolution: ManualChartResolution,
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ applyBufferedPanExpansion,
4
+ consumeScrollPanCellDelta,
5
+ consumeScrollPanMovement,
6
+ getDragPanPointDelta,
7
+ getDragPanWindowRatio,
8
+ getKeyboardPanCellCount,
9
+ resolveDragPanOffset,
10
+ resolveHorizontalScrollPanDirection,
11
+ } from "./chart-scroll";
12
+
13
+ describe("chart-scroll", () => {
14
+ test("maps every wheel direction onto horizontal panning", () => {
15
+ expect(resolveHorizontalScrollPanDirection("up")).toBe(1);
16
+ expect(resolveHorizontalScrollPanDirection("left")).toBe(1);
17
+ expect(resolveHorizontalScrollPanDirection("down")).toBe(-1);
18
+ expect(resolveHorizontalScrollPanDirection("right")).toBe(-1);
19
+ });
20
+
21
+ test("accumulates wheel input before panning a full cell", () => {
22
+ let result = consumeScrollPanCellDelta(100, 1, 1, 0);
23
+ expect(result.cells).toBe(0);
24
+ expect(result.remainder).toBeCloseTo(0.5);
25
+
26
+ result = consumeScrollPanCellDelta(100, 1, 1, result.remainder);
27
+ expect(result.cells).toBe(1);
28
+ expect(result.remainder).toBeCloseTo(0);
29
+
30
+ result = consumeScrollPanCellDelta(100, 0.4, -1, result.remainder);
31
+ expect(result.cells).toBe(0);
32
+ expect(result.remainder).toBeCloseTo(-0.2);
33
+ });
34
+
35
+ test("uses slower pan distances for keyboard and drag input", () => {
36
+ expect(getKeyboardPanCellCount(100)).toBe(2);
37
+ expect(getDragPanPointDelta(10, 100, 200)).toBe(4);
38
+ expect(getDragPanWindowRatio(10, 100)).toBeCloseTo(0.02);
39
+ expect(resolveDragPanOffset(20, 10, 100, 200, 40)).toBe(16);
40
+ expect(resolveDragPanOffset(2, 10, 100, 200, 40)).toBe(0);
41
+ });
42
+
43
+ test("resolves wheel direction, cell delta, remainder, and ratio together", () => {
44
+ const first = consumeScrollPanMovement(100, 1, "up", 0);
45
+ expect(first).toEqual({ cells: 0, remainder: 0.5, ratio: 0 });
46
+
47
+ const second = consumeScrollPanMovement(100, 1, "up", first.remainder);
48
+ expect(second).toEqual({ cells: 1, remainder: 0, ratio: 0.01 });
49
+ });
50
+
51
+ test("preserves the active cursor when widening the buffered pan range", () => {
52
+ const next = applyBufferedPanExpansion({
53
+ presetRange: "1W" as const,
54
+ bufferRange: "1M" as const,
55
+ activePreset: "1W" as const,
56
+ panOffset: 12,
57
+ zoomLevel: 2,
58
+ cursorX: 18,
59
+ cursorY: 6,
60
+ }, "3M");
61
+
62
+ expect(next).toMatchObject({
63
+ activePreset: null,
64
+ bufferRange: "3M",
65
+ cursorX: 18,
66
+ cursorY: 6,
67
+ });
68
+ });
69
+ });