gloomberb 0.3.3 → 0.4.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 (143) hide show
  1. package/README.md +27 -16
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-renderer.ts +7 -2
  17. package/src/components/chart/chart-resolution.test.ts +56 -0
  18. package/src/components/chart/chart-resolution.ts +362 -0
  19. package/src/components/chart/chart-types.ts +12 -4
  20. package/src/components/chart/chart-viewport.test.ts +25 -0
  21. package/src/components/chart/chart-viewport.ts +28 -0
  22. package/src/components/chart/chart.test.ts +26 -0
  23. package/src/components/chart/comparison-chart-data.ts +20 -27
  24. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  25. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  26. package/src/components/chart/stock-chart.tsx +484 -112
  27. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  28. package/src/components/command-bar/command-bar.test.tsx +650 -5
  29. package/src/components/command-bar/command-bar.tsx +359 -63
  30. package/src/components/command-bar/command-registry.ts +8 -0
  31. package/src/components/command-bar/helpers.test.ts +23 -1
  32. package/src/components/command-bar/helpers.ts +14 -5
  33. package/src/components/command-bar/pane-template-display.ts +12 -0
  34. package/src/components/command-bar/root-shortcuts.ts +2 -1
  35. package/src/components/command-bar/workflow-ops.ts +4 -2
  36. package/src/components/detail-feed-view.test.tsx +124 -49
  37. package/src/components/detail-feed-view.tsx +396 -160
  38. package/src/components/layout/floating-pane.tsx +1 -1
  39. package/src/components/layout/pane-header.tsx +26 -2
  40. package/src/components/layout/shell.tsx +62 -50
  41. package/src/components/layout/status-bar.test.tsx +66 -0
  42. package/src/components/layout/status-bar.tsx +26 -5
  43. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  44. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  45. package/src/components/ticker-list-table.test.tsx +83 -0
  46. package/src/components/ticker-list-table.tsx +174 -97
  47. package/src/components/ui/data-table.tsx +27 -2
  48. package/src/components/ui/index.ts +1 -0
  49. package/src/components/ui/page-stack-view.tsx +60 -0
  50. package/src/components/use-double-click-activation.ts +31 -0
  51. package/src/index.tsx +10 -10
  52. package/src/market-data/coordinator.test.ts +92 -4
  53. package/src/market-data/coordinator.ts +32 -5
  54. package/src/market-data/hooks.test.tsx +120 -0
  55. package/src/market-data/hooks.tsx +53 -16
  56. package/src/market-data/request-types.ts +4 -2
  57. package/src/market-data/selectors.ts +24 -58
  58. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  59. package/src/plugins/builtin/chat-controller.ts +310 -35
  60. package/src/plugins/builtin/chat.test.tsx +118 -6
  61. package/src/plugins/builtin/chat.tsx +175 -116
  62. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  63. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  64. package/src/plugins/builtin/news.tsx +1 -11
  65. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  66. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  67. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  68. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  69. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  70. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  71. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  72. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  73. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  74. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  75. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  76. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  77. package/src/plugins/builtin/sec.tsx +1 -11
  78. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  79. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  80. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  81. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  82. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  83. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  84. package/src/plugins/catalog.ts +65 -0
  85. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  86. package/src/plugins/ibkr/gateway-service.test.ts +75 -1
  87. package/src/plugins/ibkr/gateway-service.ts +179 -9
  88. package/src/plugins/pane-manager.test.ts +1 -1
  89. package/src/plugins/plugin-runtime.tsx +27 -7
  90. package/src/plugins/prediction-markets/chart.test.tsx +139 -0
  91. package/src/plugins/prediction-markets/chart.tsx +65 -436
  92. package/src/plugins/prediction-markets/cli.ts +28 -0
  93. package/src/plugins/prediction-markets/controller-data.ts +17 -5
  94. package/src/plugins/prediction-markets/controller.ts +141 -121
  95. package/src/plugins/prediction-markets/detail/outcomes.tsx +2 -6
  96. package/src/plugins/prediction-markets/detail/pane.tsx +4 -25
  97. package/src/plugins/prediction-markets/detail/shared.tsx +0 -1
  98. package/src/plugins/prediction-markets/detail.test.tsx +4 -0
  99. package/src/plugins/prediction-markets/index.tsx +2 -0
  100. package/src/plugins/prediction-markets/launch.ts +51 -16
  101. package/src/plugins/prediction-markets/metrics.ts +9 -5
  102. package/src/plugins/prediction-markets/pane.test.tsx +160 -58
  103. package/src/plugins/prediction-markets/pane.tsx +74 -183
  104. package/src/plugins/prediction-markets/table.tsx +6 -3
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/sources/gloomberb-cloud.test.ts +29 -0
  107. package/src/sources/gloomberb-cloud.ts +89 -1
  108. package/src/sources/provider-router.test.ts +308 -3
  109. package/src/sources/provider-router.ts +247 -17
  110. package/src/sources/yahoo-finance.test.ts +26 -0
  111. package/src/sources/yahoo-finance.ts +54 -3
  112. package/src/state/app-context-selector.test.tsx +99 -0
  113. package/src/state/app-context.test.ts +18 -0
  114. package/src/state/app-context.tsx +170 -65
  115. package/src/state/selectors.ts +25 -0
  116. package/src/theme/colors.ts +8 -8
  117. package/src/theme/themes.test.ts +2 -0
  118. package/src/theme/themes.ts +22 -10
  119. package/src/types/broker.ts +21 -0
  120. package/src/types/config.ts +17 -1
  121. package/src/types/data-provider.ts +18 -0
  122. package/src/types/financials.ts +31 -1
  123. package/src/types/plugin.ts +70 -0
  124. package/src/utils/api-client.test.ts +57 -0
  125. package/src/utils/api-client.ts +35 -12
  126. package/src/utils/back-navigation.ts +27 -0
  127. package/src/utils/format.test.ts +14 -0
  128. package/src/utils/format.ts +3 -3
  129. package/src/utils/market-status.ts +46 -1
  130. package/src/utils/price-history.test.ts +25 -0
  131. package/src/utils/price-history.ts +29 -5
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,14 +1,51 @@
1
- import { useEffect, useMemo, useRef, useState } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { TextAttributes, type BoxRenderable, type CliRenderer } from "@opentui/core";
3
3
  import { useKeyboard, useRenderer } from "@opentui/react";
4
- import { useAppState, usePaneInstanceId, usePaneTicker } from "../../state/app-context";
4
+ import { useAppDispatch, useAppSelector, usePaneInstanceId, usePaneSettingValue, usePaneTicker } from "../../state/app-context";
5
5
  import { saveConfig } from "../../data/config-store";
6
+ import { getSharedDataProvider } from "../../plugins/registry";
6
7
  import { colors, priceColor } from "../../theme/colors";
7
8
  import { formatCompact, formatCurrency } from "../../utils/format";
8
- import { useChartQuery, useResolvedEntryValue } from "../../market-data/hooks";
9
+ import { useChartQuery } from "../../market-data/hooks";
9
10
  import { instrumentFromTicker } from "../../market-data/request-types";
10
- import { filterByTimeRange, getVisibleWindow, projectChartData, resolveBarSize } from "./chart-data";
11
- import { clampChartZoom, getVisiblePointCount } from "./chart-viewport";
11
+ import { usePersistChartControlSelection } from "./chart-pane-settings";
12
+ import { getVisibleWindow, projectChartData, resolveBarSize } from "./chart-data";
13
+ import {
14
+ buildVisibleDateWindow,
15
+ clearActivePreset,
16
+ formatVisibleSpanLabel,
17
+ getCanonicalZoomLevel,
18
+ getPointDates,
19
+ isCanonicalPresetViewport,
20
+ resolveChartBodyState,
21
+ resolvePresetSelection,
22
+ resolvePresetSelectionWithResolution,
23
+ resolveResolutionSelection,
24
+ resolveStoredChartSelection,
25
+ } from "./chart-controller";
26
+ import {
27
+ buildChartResolutionSupportMap,
28
+ clampTimeRangeToMaxRange,
29
+ DEFAULT_TICKER_CHART_RANGE_PRESET,
30
+ DEFAULT_TICKER_CHART_RESOLUTION,
31
+ DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS,
32
+ getBestSupportedResolutionForPreset,
33
+ getChartResolutionLabel,
34
+ getNextFallbackResolution,
35
+ getNextBufferRange,
36
+ getPresetResolution,
37
+ getSupportMaxRange,
38
+ isRangePresetSupported,
39
+ normalizeChartResolutionSupport,
40
+ sortChartResolutions,
41
+ type ChartResolutionSupport,
42
+ type ManualChartResolution,
43
+ } from "./chart-resolution";
44
+ import {
45
+ getVisiblePointCount,
46
+ RIGHT_EDGE_ANCHOR_RATIO,
47
+ resolveAnchoredChartZoom,
48
+ } from "./chart-viewport";
12
49
  import {
13
50
  buildChartScene,
14
51
  formatDateShort,
@@ -30,6 +67,7 @@ import {
30
67
  TIME_RANGES,
31
68
  type ChartAxisMode,
32
69
  type ChartRenderMode,
70
+ type ChartResolution,
33
71
  type ChartViewState,
34
72
  type ResolvedChartRenderer,
35
73
  } from "./chart-types";
@@ -46,7 +84,8 @@ import { ensureKittySupport, getCachedKittySupport } from "./native/kitty-suppor
46
84
  import { resolveChartRendererState } from "./native/renderer-selection";
47
85
  import { getNativeSurfaceManager } from "./native/surface-manager";
48
86
  import { syncCachedNativeSurface } from "./native/surface-sync";
49
- import type { PricePoint } from "../../types/financials";
87
+ import type { PricePoint, TickerFinancials } from "../../types/financials";
88
+ import type { TickerRecord } from "../../types/ticker";
50
89
 
51
90
  const MODE_CHIPS: Record<ChartRenderMode, string> = {
52
91
  area: "A",
@@ -69,6 +108,14 @@ interface StockChartProps {
69
108
  interactive?: boolean;
70
109
  compact?: boolean;
71
110
  axisMode?: ChartAxisMode;
111
+ historyOverride?: PricePoint[] | null;
112
+ currencyOverride?: string | null;
113
+ }
114
+
115
+ interface ResolvedStockChartProps extends StockChartProps {
116
+ symbol: string | null;
117
+ ticker: TickerRecord | null;
118
+ financials: TickerFinancials | null;
72
119
  }
73
120
 
74
121
  interface DragState {
@@ -76,6 +123,11 @@ interface DragState {
76
123
  startPanOffset: number;
77
124
  }
78
125
 
126
+ type PendingExpansionAction =
127
+ | { kind: "zoom-out"; targetVisibleCount: number; anchorRatio: number }
128
+ | { kind: "pan-left"; targetPanOffset: number }
129
+ | null;
130
+
79
131
  interface ChartMouseEvent {
80
132
  x: number;
81
133
  y: number;
@@ -398,10 +450,9 @@ function formatAxisCell(label: string | null, width: number): string {
398
450
  return label.length >= width ? label.slice(0, width) : label.padStart(width);
399
451
  }
400
452
 
401
- function getMaxPanOffset(history: PricePoint[], timeRange: ChartViewState["timeRange"], zoomLevel: number, chartWidth: number): number {
402
- const filtered = filterByTimeRange(history, timeRange);
403
- const visibleCount = getVisiblePointCount(filtered.length, zoomLevel);
404
- return Math.max(filtered.length - visibleCount, 0);
453
+ function getMaxPanOffset(history: PricePoint[], zoomLevel: number): number {
454
+ const visibleCount = getVisiblePointCount(history.length, zoomLevel);
455
+ return Math.max(history.length - visibleCount, 0);
405
456
  }
406
457
 
407
458
  function applyZoomAroundAnchor(
@@ -409,25 +460,20 @@ function applyZoomAroundAnchor(
409
460
  nextZoomLevel: number,
410
461
  anchorRatio: number,
411
462
  history: PricePoint[],
412
- chartWidth: number,
413
463
  ): ChartViewState {
414
- const filtered = filterByTimeRange(history, view.timeRange);
415
- if (filtered.length === 0) return view;
416
-
417
- const clampedZoom = clampChartZoom(filtered.length, nextZoomLevel);
418
- const currentVisibleCount = getVisiblePointCount(filtered.length, view.zoomLevel);
419
- const nextVisibleCount = getVisiblePointCount(filtered.length, clampedZoom);
420
- const currentPanOffset = clamp(view.panOffset, 0, Math.max(filtered.length - currentVisibleCount, 0));
421
- const ratio = clamp(anchorRatio, 0, 1);
422
- const anchorIndex = filtered.length - currentPanOffset - currentVisibleCount + ratio * Math.max(currentVisibleCount - 1, 0);
423
- const nextStart = Math.round(anchorIndex - ratio * Math.max(nextVisibleCount - 1, 0));
424
- const clampedStart = clamp(nextStart, 0, Math.max(filtered.length - nextVisibleCount, 0));
425
- const nextPanOffset = filtered.length - nextVisibleCount - clampedStart;
464
+ if (history.length === 0) return view;
465
+
466
+ const nextZoom = resolveAnchoredChartZoom(
467
+ history.length,
468
+ view.zoomLevel,
469
+ view.panOffset,
470
+ nextZoomLevel,
471
+ anchorRatio,
472
+ );
426
473
 
427
474
  return {
428
- ...view,
429
- zoomLevel: clampedZoom,
430
- panOffset: clamp(nextPanOffset, 0, Math.max(filtered.length - nextVisibleCount, 0)),
475
+ ...clearActivePreset(view),
476
+ ...nextZoom,
431
477
  };
432
478
  }
433
479
 
@@ -505,22 +551,46 @@ function resolveSelectionCursor(
505
551
  };
506
552
  }
507
553
 
508
- export function StockChart({ width, height, focused, interactive, compact, axisMode = "price" }: StockChartProps) {
554
+ export function StockChart(props: StockChartProps) {
555
+ const { symbol, ticker, financials } = usePaneTicker();
556
+ return <ResolvedStockChart {...props} symbol={symbol} ticker={ticker} financials={financials} />;
557
+ }
558
+
559
+ export const ResolvedStockChart = memo(function ResolvedStockChart({
560
+ width,
561
+ height,
562
+ focused,
563
+ interactive,
564
+ compact,
565
+ axisMode = "price",
566
+ historyOverride = null,
567
+ currencyOverride = null,
568
+ symbol,
569
+ ticker,
570
+ financials,
571
+ }: ResolvedStockChartProps) {
509
572
  const renderer = useRenderer();
510
- const { state, dispatch } = useAppState();
573
+ const dispatch = useAppDispatch();
574
+ const config = useAppSelector((state) => state.config);
511
575
  const paneId = usePaneInstanceId();
512
- const { ticker, financials } = usePaneTicker();
513
576
  const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
514
- const defaultRenderMode = state.config.chartPreferences.defaultRenderMode;
515
- const preferredRenderer = state.config.chartPreferences.renderer;
577
+ const defaultRenderMode = config.chartPreferences.defaultRenderMode;
578
+ const preferredRenderer = config.chartPreferences.renderer;
579
+ const [storedRangePreset] = usePaneSettingValue("chartRangePreset", DEFAULT_TICKER_CHART_RANGE_PRESET);
580
+ const [storedResolution] = usePaneSettingValue<ChartResolution>("chartResolution", DEFAULT_TICKER_CHART_RESOLUTION);
581
+ const persistChartControls = usePersistChartControlSelection("chartRangePreset");
516
582
  const [viewState, setViewState] = useState<ChartViewState>({
517
- timeRange: compact ? "1Y" : "5Y",
583
+ presetRange: compact ? "1Y" : storedRangePreset,
584
+ bufferRange: compact ? "1Y" : storedRangePreset,
585
+ activePreset: compact ? null : storedRangePreset,
586
+ resolution: compact ? "auto" : storedResolution,
518
587
  panOffset: 0,
519
588
  zoomLevel: 1,
520
589
  cursorX: null,
521
590
  cursorY: null,
522
591
  renderMode: defaultRenderMode,
523
592
  });
593
+ const [resolutionSupport, setResolutionSupport] = useState<ChartResolutionSupport[] | null>(null);
524
594
  const [showVolume, setShowVolume] = useState(!compact);
525
595
  const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
526
596
  const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
@@ -535,6 +605,9 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
535
605
  const targetCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
536
606
  const cursorMotionKindRef = useRef<ChartCursorMotionKind>("discrete");
537
607
  const animationFrameRef = useRef<number | null>(null);
608
+ const pendingCanonicalResetRef = useRef(1);
609
+ const appliedCanonicalResetRef = useRef(0);
610
+ const pendingExpansionRef = useRef<PendingExpansionAction>(null);
538
611
 
539
612
  const commitDisplayCursor = (next: DisplayCursorState) => {
540
613
  displayCursorRef.current = next;
@@ -620,16 +693,107 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
620
693
  () => instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null),
621
694
  [ticker],
622
695
  );
696
+ const capabilityKey = instrumentRef ? [
697
+ instrumentRef.symbol,
698
+ instrumentRef.exchange ?? "",
699
+ instrumentRef.brokerId ?? "",
700
+ instrumentRef.brokerInstanceId ?? "",
701
+ instrumentRef.instrument?.conId ?? "",
702
+ ].join("|") : null;
703
+
704
+ useEffect(() => {
705
+ if (compact) return;
706
+ pendingCanonicalResetRef.current += 1;
707
+ setViewState((current) => {
708
+ const nextState = resolvePresetSelection(
709
+ {
710
+ ...current,
711
+ resolution: storedResolution,
712
+ },
713
+ storedRangePreset,
714
+ storedResolution === "auto" ? null : getSupportMaxRange(resolutionSupport ?? [], storedResolution),
715
+ );
716
+ return nextState;
717
+ });
718
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
719
+ }, [compact, resolutionSupport, storedRangePreset, storedResolution]);
720
+
721
+ useEffect(() => {
722
+ if (compact || !instrumentRef) {
723
+ setResolutionSupport(null);
724
+ return;
725
+ }
726
+ const provider = getSharedDataProvider();
727
+ if (!provider?.getChartResolutionSupport && !provider?.getChartResolutionCapabilities) {
728
+ setResolutionSupport(null);
729
+ return;
730
+ }
731
+
732
+ let cancelled = false;
733
+ setResolutionSupport(null);
734
+ Promise.resolve(provider.getChartResolutionSupport
735
+ ? provider.getChartResolutionSupport(
736
+ instrumentRef.symbol,
737
+ instrumentRef.exchange ?? "",
738
+ {
739
+ brokerId: instrumentRef.brokerId,
740
+ brokerInstanceId: instrumentRef.brokerInstanceId,
741
+ instrument: instrumentRef.instrument ?? null,
742
+ },
743
+ )
744
+ : normalizeChartResolutionSupport(
745
+ (provider.getChartResolutionCapabilities?.(
746
+ instrumentRef.symbol,
747
+ instrumentRef.exchange ?? "",
748
+ {
749
+ brokerId: instrumentRef.brokerId,
750
+ brokerInstanceId: instrumentRef.brokerInstanceId,
751
+ instrument: instrumentRef.instrument ?? null,
752
+ },
753
+ ) ?? []).map((resolution) => ({ resolution, maxRange: "ALL" })),
754
+ )
755
+ ).then((support) => {
756
+ if (!cancelled) {
757
+ setResolutionSupport(support);
758
+ }
759
+ }).catch(() => {
760
+ if (!cancelled) {
761
+ setResolutionSupport(null);
762
+ }
763
+ });
764
+
765
+ return () => {
766
+ cancelled = true;
767
+ };
768
+ }, [capabilityKey, compact, instrumentRef]);
769
+
770
+ const supportMap = useMemo(() => buildChartResolutionSupportMap(resolutionSupport ?? []), [resolutionSupport]);
771
+ const effectiveResolutionSupport = useMemo<ChartResolutionSupport[]>(() => (
772
+ resolutionSupport ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS.map((resolution) => ({ resolution, maxRange: "ALL" as const }))
773
+ ), [resolutionSupport]);
774
+ const availableManualResolutions = resolutionSupport?.map((entry) => entry.resolution) ?? DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS;
775
+ const effectiveResolution: ChartResolution = !compact
776
+ && viewState.resolution !== "auto"
777
+ && resolutionSupport !== null
778
+ && !supportMap.has(viewState.resolution)
779
+ ? "auto"
780
+ : viewState.resolution;
781
+ const resolutionChips = useMemo(
782
+ () => sortChartResolutions(["auto", ...availableManualResolutions] as ChartResolution[]),
783
+ [availableManualResolutions],
784
+ );
785
+
623
786
  const baseChartEntry = useChartQuery(
624
787
  !compact && instrumentRef
625
788
  ? {
626
789
  instrument: instrumentRef,
627
- range: viewState.timeRange,
628
- granularity: "daily",
790
+ bufferRange: viewState.bufferRange,
791
+ granularity: effectiveResolution === "auto" ? "range" : "resolution",
792
+ resolution: effectiveResolution === "auto" ? undefined : effectiveResolution,
629
793
  }
630
794
  : null,
631
795
  );
632
- const rangeHistory = useResolvedEntryValue(baseChartEntry);
796
+ const baseBodyState = resolveChartBodyState(baseChartEntry, (value) => Array.isArray(value) && value.length > 0, "No price history available.");
633
797
  const axisWidth = compact
634
798
  ? axisMode === "percent" ? 11 : 8
635
799
  : axisMode === "percent" ? 11 : 10;
@@ -637,11 +801,11 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
637
801
  const chartWidth = Math.max(width - axisWidth - axisGap, compact ? 12 : 20);
638
802
  const maxCursorX = chartWidth - 1;
639
803
  const panStep = Math.max(Math.floor(chartWidth / 10), 1);
640
- const baseHistory = rangeHistory && rangeHistory.length > 0
641
- ? rangeHistory
642
- : (financials?.priceHistory ?? []);
804
+ const baseHistory = compact
805
+ ? (historyOverride ?? financials?.priceHistory ?? [])
806
+ : (baseBodyState.data ?? []);
643
807
  const detailRequest = useMemo(() => {
644
- if (compact || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
808
+ if (compact || effectiveResolution !== "auto" || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
645
809
  const window = getVisibleWindow(baseHistory, viewState, chartWidth);
646
810
  if (window.points.length < 2) return null;
647
811
 
@@ -654,17 +818,70 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
654
818
 
655
819
  return {
656
820
  instrument: instrumentRef,
657
- range: viewState.timeRange,
821
+ bufferRange: viewState.bufferRange,
658
822
  granularity: "detail" as const,
659
823
  startDate,
660
824
  endDate,
661
825
  barSize,
662
826
  };
663
- }, [baseHistory, chartWidth, compact, instrumentRef, viewState]);
827
+ }, [baseHistory, chartWidth, compact, effectiveResolution, instrumentRef, viewState]);
664
828
  const detailChartEntry = useChartQuery(detailRequest);
665
- const detailHistory = useResolvedEntryValue(detailChartEntry);
829
+ const detailBodyState = resolveChartBodyState(detailChartEntry, (value) => Array.isArray(value) && value.length > 0, "No price history available.");
830
+ const history = (effectiveResolution === "auto" && viewState.zoomLevel > 1 && detailBodyState.data) ? detailBodyState.data : baseHistory;
831
+ const baseHistoryDates = useMemo(() => getPointDates(baseHistory), [baseHistory]);
832
+ const visibleDateWindow = useMemo(() => buildVisibleDateWindow(baseHistoryDates, viewState.panOffset, viewState.zoomLevel), [baseHistoryDates, viewState.panOffset, viewState.zoomLevel]);
833
+ const activePreset = compact || !isCanonicalPresetViewport(baseHistoryDates, {
834
+ activePreset: viewState.activePreset,
835
+ panOffset: viewState.panOffset,
836
+ zoomLevel: viewState.zoomLevel,
837
+ resolution: effectiveResolution,
838
+ })
839
+ ? null
840
+ : viewState.activePreset;
666
841
 
667
- const history = (viewState.zoomLevel > 1 && detailHistory) ? detailHistory : baseHistory;
842
+ useEffect(() => {
843
+ if (compact || baseHistoryDates.length === 0) return;
844
+
845
+ if (appliedCanonicalResetRef.current < pendingCanonicalResetRef.current) {
846
+ const canonicalZoom = getCanonicalZoomLevel(baseHistoryDates, viewState.presetRange);
847
+ appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
848
+ setViewState((current) => (
849
+ current.zoomLevel === canonicalZoom && current.panOffset === 0
850
+ ? current
851
+ : {
852
+ ...current,
853
+ zoomLevel: canonicalZoom,
854
+ panOffset: 0,
855
+ cursorX: null,
856
+ cursorY: null,
857
+ }
858
+ ));
859
+ return;
860
+ }
861
+
862
+ if (!pendingExpansionRef.current) return;
863
+ const pendingExpansion = pendingExpansionRef.current;
864
+ pendingExpansionRef.current = null;
865
+ setViewState((current) => {
866
+ if (pendingExpansion.kind === "zoom-out") {
867
+ const nextVisibleCount = Math.min(baseHistoryDates.length, Math.max(pendingExpansion.targetVisibleCount, 1));
868
+ return {
869
+ ...current,
870
+ ...resolveAnchoredChartZoom(
871
+ baseHistoryDates.length,
872
+ 1,
873
+ 0,
874
+ baseHistoryDates.length / nextVisibleCount,
875
+ pendingExpansion.anchorRatio,
876
+ ),
877
+ };
878
+ }
879
+ return {
880
+ ...current,
881
+ panOffset: clamp(pendingExpansion.targetPanOffset, 0, getMaxPanOffset(baseHistory, current.zoomLevel)),
882
+ };
883
+ });
884
+ }, [baseHistory, baseHistoryDates, compact, viewState.presetRange]);
668
885
 
669
886
  useEffect(() => {
670
887
  if (interactive) {
@@ -705,9 +922,9 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
705
922
  const persistDefaultRenderMode = (nextMode: ChartRenderMode) => {
706
923
  if (nextMode === defaultRenderMode) return;
707
924
  const nextConfig = {
708
- ...state.config,
925
+ ...config,
709
926
  chartPreferences: {
710
- ...state.config.chartPreferences,
927
+ ...config.chartPreferences,
711
928
  defaultRenderMode: nextMode,
712
929
  },
713
930
  };
@@ -715,16 +932,43 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
715
932
  saveConfig(nextConfig).catch(() => {});
716
933
  };
717
934
 
718
- const setRange = (range: ChartViewState["timeRange"]) => {
719
- updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
935
+ const expandBufferRange = (action: PendingExpansionAction): boolean => {
936
+ if (compact) return false;
937
+ const nextCandidate = getNextBufferRange(viewState.bufferRange);
938
+ const nextBufferRange = effectiveResolution === "auto"
939
+ ? nextCandidate
940
+ : clampTimeRangeToMaxRange(nextCandidate, supportMap.get(effectiveResolution) ?? viewState.bufferRange);
941
+ if (nextBufferRange === viewState.bufferRange) return false;
942
+ pendingExpansionRef.current = action;
720
943
  setViewState((current) => ({
721
- ...current,
722
- timeRange: range,
723
- panOffset: 0,
724
- zoomLevel: 1,
944
+ ...clearActivePreset(current),
945
+ bufferRange: nextBufferRange,
725
946
  cursorX: null,
726
947
  cursorY: null,
727
948
  }));
949
+ return true;
950
+ };
951
+
952
+ const setRange = (range: TimeRange) => {
953
+ if (!compact && !isRangePresetSupported(range, effectiveResolutionSupport)) return;
954
+ const supportMaxRange = getSupportMaxRange(effectiveResolutionSupport, getPresetResolution(range));
955
+ if (!compact) {
956
+ persistChartControls(range, getPresetResolution(range));
957
+ }
958
+ pendingCanonicalResetRef.current += 1;
959
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
960
+ setViewState((current) => resolvePresetSelection(current, range, supportMaxRange));
961
+ };
962
+
963
+ const setResolution = (resolution: ChartResolution) => {
964
+ if (compact) return;
965
+ if (resolution !== "auto" && !availableManualResolutions.includes(resolution)) return;
966
+ const nextState = resolveResolutionSelection(viewState, resolution, supportMap, visibleDateWindow);
967
+ if (!nextState) return;
968
+ pendingCanonicalResetRef.current += 1;
969
+ persistChartControls(nextState.presetRange, resolution);
970
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
971
+ setViewState(nextState);
728
972
  };
729
973
 
730
974
  const setRenderMode = (mode: ChartRenderMode) => {
@@ -732,12 +976,12 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
732
976
  setViewState((current) => ({ ...current, renderMode: mode }));
733
977
  };
734
978
 
735
- const headerRows = compact ? 0 : 3;
979
+ const headerRows = compact ? 0 : 4;
736
980
  const helpRow = compact ? 0 : 1;
737
981
  const timeAxisRow = 1;
738
982
  const volumeHeight = showVolume && !compact ? 3 : 0;
739
983
  const chartHeight = Math.max(height - headerRows - helpRow - timeAxisRow, 4);
740
- const isDetailView = viewState.zoomLevel > 1 && detailHistory != null && detailHistory.length > 0;
984
+ const isDetailView = effectiveResolution === "auto" && viewState.zoomLevel > 1 && !!detailBodyState.data?.length;
741
985
  const historyRenderKey = history.length === 0
742
986
  ? "empty"
743
987
  : [
@@ -751,11 +995,18 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
751
995
  queueMicrotask(() => renderer.requestRender());
752
996
  }, [chartHeight, chartWidth, compact, historyRenderKey, renderer, ticker?.metadata.ticker, viewState.renderMode]);
753
997
 
754
- const chartWindow = useMemo(() => (
755
- isDetailView
998
+ const chartWindow = useMemo(() => {
999
+ if (historyOverride) {
1000
+ return { points: history, startIdx: 0, endIdx: history.length };
1001
+ }
1002
+ return isDetailView
756
1003
  ? { points: history, startIdx: 0, endIdx: history.length }
757
- : getVisibleWindow(history, viewState, chartWidth)
758
- ), [chartWidth, history, isDetailView, viewState.panOffset, viewState.timeRange, viewState.zoomLevel]);
1004
+ : getVisibleWindow(history, viewState, chartWidth);
1005
+ }, [chartWidth, history, historyOverride, isDetailView, viewState.panOffset, viewState.zoomLevel]);
1006
+ const timeAxisDates = useMemo(
1007
+ () => chartWindow.points.map((point) => point.date),
1008
+ [chartWindow.points],
1009
+ );
759
1010
 
760
1011
  const projection = useMemo(() => (
761
1012
  projectChartData(chartWindow.points, chartWidth, viewState.renderMode, !!compact)
@@ -766,23 +1017,56 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
766
1017
 
767
1018
  switch (event.name) {
768
1019
  case "=":
769
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.5, 0.5, baseHistory, chartWidth));
1020
+ setViewState((current) => applyZoomAroundAnchor(
1021
+ current,
1022
+ current.zoomLevel * 1.5,
1023
+ RIGHT_EDGE_ANCHOR_RATIO,
1024
+ baseHistory,
1025
+ ));
770
1026
  return;
771
1027
  case "-":
772
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.5, 0.5, baseHistory, chartWidth));
1028
+ if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1029
+ kind: "zoom-out",
1030
+ targetVisibleCount: Math.round(baseHistory.length * 1.5),
1031
+ anchorRatio: RIGHT_EDGE_ANCHOR_RATIO,
1032
+ })) {
1033
+ return;
1034
+ }
1035
+ setViewState((current) => applyZoomAroundAnchor(
1036
+ current,
1037
+ current.zoomLevel / 1.5,
1038
+ RIGHT_EDGE_ANCHOR_RATIO,
1039
+ baseHistory,
1040
+ ));
773
1041
  return;
774
1042
  case "0":
1043
+ pendingCanonicalResetRef.current += 1;
775
1044
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
776
- setViewState((current) => ({ ...current, panOffset: 0, zoomLevel: 1, cursorX: null, cursorY: null }));
1045
+ setViewState((current) => {
1046
+ const nextState = resolveResolutionSelection(current, effectiveResolution, supportMap, visibleDateWindow) ?? current;
1047
+ return {
1048
+ ...nextState,
1049
+ panOffset: 0,
1050
+ zoomLevel: 1,
1051
+ cursorX: null,
1052
+ cursorY: null,
1053
+ };
1054
+ });
777
1055
  return;
778
1056
  case "v":
779
1057
  setShowVolume((value) => !value);
780
1058
  return;
781
1059
  case "a":
782
- setViewState((current) => ({ ...current, panOffset: current.panOffset + panStep }));
1060
+ if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1061
+ kind: "pan-left",
1062
+ targetPanOffset: viewState.panOffset + panStep,
1063
+ })) {
1064
+ return;
1065
+ }
1066
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: current.panOffset + panStep }));
783
1067
  return;
784
1068
  case "d":
785
- setViewState((current) => ({ ...current, panOffset: Math.max(current.panOffset - panStep, 0) }));
1069
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: Math.max(current.panOffset - panStep, 0) }));
786
1070
  return;
787
1071
  case "m":
788
1072
  setViewState((current) => {
@@ -793,6 +1077,12 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
793
1077
  return { ...current, renderMode: nextMode };
794
1078
  });
795
1079
  return;
1080
+ case "r": {
1081
+ const currentIndex = resolutionChips.indexOf(effectiveResolution);
1082
+ const nextResolution = resolutionChips[(currentIndex + 1) % resolutionChips.length] ?? "auto";
1083
+ setResolution(nextResolution);
1084
+ return;
1085
+ }
796
1086
  }
797
1087
 
798
1088
  if (event.name >= "1" && event.name <= "7") {
@@ -806,7 +1096,13 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
806
1096
  switch (event.name) {
807
1097
  case "left":
808
1098
  if (event.shift) {
809
- setViewState((current) => ({ ...current, panOffset: current.panOffset + panStep }));
1099
+ if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1100
+ kind: "pan-left",
1101
+ targetPanOffset: viewState.panOffset + panStep,
1102
+ })) {
1103
+ return;
1104
+ }
1105
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: current.panOffset + panStep }));
810
1106
  } else {
811
1107
  cursorMotionKindRef.current = "discrete";
812
1108
  setViewState((current) => {
@@ -826,10 +1122,10 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
826
1122
  chartWidth,
827
1123
  projection.effectiveMode,
828
1124
  );
829
- const maxPan = getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth);
1125
+ const maxPan = getMaxPanOffset(baseHistory, current.zoomLevel);
830
1126
  if (currentIndex <= 0) {
831
1127
  return {
832
- ...current,
1128
+ ...clearActivePreset(current),
833
1129
  cursorX: nextCursor,
834
1130
  cursorY: null,
835
1131
  panOffset: clamp(current.panOffset + 1, 0, maxPan),
@@ -841,7 +1137,7 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
841
1137
  return;
842
1138
  case "right":
843
1139
  if (event.shift) {
844
- setViewState((current) => ({ ...current, panOffset: Math.max(current.panOffset - panStep, 0) }));
1140
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: Math.max(current.panOffset - panStep, 0) }));
845
1141
  } else {
846
1142
  cursorMotionKindRef.current = "discrete";
847
1143
  setViewState((current) => {
@@ -863,7 +1159,7 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
863
1159
  );
864
1160
  if (currentIndex >= pointCount - 1) {
865
1161
  return {
866
- ...current,
1162
+ ...clearActivePreset(current),
867
1163
  cursorX: nextCursor,
868
1164
  cursorY: null,
869
1165
  panOffset: Math.max(current.panOffset - 1, 0),
@@ -891,7 +1187,7 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
891
1187
  negative: colors.negative,
892
1188
  }, trend);
893
1189
  }, [chartWindow.points]);
894
- const chartCurrency = financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
1190
+ const chartCurrency = currencyOverride ?? financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
895
1191
 
896
1192
  const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
897
1193
  const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
@@ -918,7 +1214,8 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
918
1214
  mode: projection.effectiveMode,
919
1215
  axisMode,
920
1216
  colors: chartColors,
921
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, volumeHeight]);
1217
+ timeAxisDates,
1218
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, timeAxisDates, volumeHeight]);
922
1219
  const snappedSelectionCursorX = selectionScene
923
1220
  ? getPointTerminalColumn(selectionScene.activeIdx, projection.points.length, chartWidth, projection.effectiveMode)
924
1221
  : null;
@@ -962,7 +1259,8 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
962
1259
  mode: projection.effectiveMode,
963
1260
  axisMode,
964
1261
  colors: chartColors,
965
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
1262
+ timeAxisDates,
1263
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
966
1264
 
967
1265
  const rendererState = resolveChartRendererState(preferredRenderer, kittySupport, renderer.resolution);
968
1266
  const effectiveRenderer: ResolvedChartRenderer = rendererState.renderer;
@@ -978,7 +1276,8 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
978
1276
  axisMode,
979
1277
  currency: chartCurrency,
980
1278
  colors: chartColors,
981
- }), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
1279
+ timeAxisDates,
1280
+ }), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
982
1281
 
983
1282
  const interactiveResult = useMemo(() => (
984
1283
  effectiveRenderer === "kitty"
@@ -994,8 +1293,9 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
994
1293
  axisMode,
995
1294
  currency: chartCurrency,
996
1295
  colors: chartColors,
1296
+ timeAxisDates,
997
1297
  })
998
- ), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
1298
+ ), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
999
1299
 
1000
1300
  const result = effectiveRenderer === "kitty" ? staticResult : interactiveResult!;
1001
1301
 
@@ -1243,10 +1543,7 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1243
1543
  rendererState.nativeReady,
1244
1544
  ]);
1245
1545
 
1246
- if (history.length === 0) {
1247
- return <text fg={colors.textDim}>No price history available.</text>;
1248
- }
1249
-
1546
+ const hasHistory = history.length > 0;
1250
1547
  const firstPrice = chartWindow.points[0]?.close ?? 0;
1251
1548
  const lastPrice = chartWindow.points[chartWindow.points.length - 1]?.close ?? 0;
1252
1549
  const change = lastPrice - firstPrice;
@@ -1255,9 +1552,21 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1255
1552
  const showOhlcSummary = projection.effectiveMode === "candles" || projection.effectiveMode === "ohlc";
1256
1553
  const hasSelectionCursor = selectionCursorX !== null;
1257
1554
  const hasDisplayCursor = displayCursorX !== null && displayCursorY !== null;
1258
- const displayPrice = hasSelectionCursor ? (selectionScene?.priceAtCursor ?? lastPrice) : lastPrice;
1259
- const displayChange = hasSelectionCursor ? (selectionScene?.changeAtCursor ?? change) : change;
1260
- const displayChangePct = hasSelectionCursor ? (selectionScene?.changePctAtCursor ?? changePct) : changePct;
1555
+ const displayPrice = !hasHistory
1556
+ ? null
1557
+ : hasSelectionCursor
1558
+ ? (selectionScene?.priceAtCursor ?? lastPrice)
1559
+ : lastPrice;
1560
+ const displayChange = !hasHistory
1561
+ ? null
1562
+ : hasSelectionCursor
1563
+ ? (selectionScene?.changeAtCursor ?? change)
1564
+ : change;
1565
+ const displayChangePct = !hasHistory
1566
+ ? null
1567
+ : hasSelectionCursor
1568
+ ? (selectionScene?.changePctAtCursor ?? changePct)
1569
+ : changePct;
1261
1570
  const displayDate = hasSelectionCursor || showOhlcSummary
1262
1571
  ? (selectionScene?.dateAtCursor ? formatDateShort(selectionScene.dateAtCursor) : null)
1263
1572
  : null;
@@ -1266,6 +1575,15 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1266
1575
  const cursorAxisLabel = hasDisplayCursor && cursorRow !== null && crosshairPrice !== null
1267
1576
  ? formatAxisValue(crosshairPrice, axisMode, projection.points[0]?.close ?? 0, chartCurrency)
1268
1577
  : null;
1578
+ const visibleLabel = formatVisibleSpanLabel({
1579
+ start: chartWindow.points[0]?.date ? coerceChartDate(chartWindow.points[0].date as Date | string | number) : null,
1580
+ end: chartWindow.points[chartWindow.points.length - 1]?.date
1581
+ ? coerceChartDate(chartWindow.points[chartWindow.points.length - 1]!.date as Date | string | number)
1582
+ : null,
1583
+ });
1584
+ const isBlockingBody = !compact && baseBodyState.blocking;
1585
+ const bodyMessage = !compact ? (baseBodyState.errorMessage ?? baseBodyState.emptyMessage) : null;
1586
+ const isUpdating = !compact && (baseBodyState.updating || (detailChartEntry?.phase === "refreshing" && !!detailBodyState.data?.length));
1269
1587
 
1270
1588
  const handlePlotMove = (event: ChartMouseEvent) => {
1271
1589
  if (!interactive || compact) return;
@@ -1329,16 +1647,15 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1329
1647
  }
1330
1648
  if (!dragRef.current) return;
1331
1649
 
1332
- const filtered = filterByTimeRange(baseHistory, viewState.timeRange);
1333
- const visibleCount = getVisiblePointCount(filtered.length, viewState.zoomLevel);
1650
+ const visibleCount = getVisiblePointCount(baseHistory.length, viewState.zoomLevel);
1334
1651
  const deltaCells = getGlobalMouseX(event, renderer) - dragRef.current.startGlobalX;
1335
1652
  const pointDelta = Math.round((deltaCells / Math.max(chartWidth, 1)) * visibleCount);
1336
1653
  const nextPan = clamp(
1337
1654
  dragRef.current.startPanOffset - pointDelta,
1338
1655
  0,
1339
- Math.max(filtered.length - visibleCount, 0),
1656
+ Math.max(baseHistory.length - visibleCount, 0),
1340
1657
  );
1341
- setViewState((current) => ({ ...current, panOffset: nextPan }));
1658
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1342
1659
  };
1343
1660
 
1344
1661
  const handlePlotScroll = (event: ChartMouseEvent) => {
@@ -1367,39 +1684,59 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1367
1684
 
1368
1685
  if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1369
1686
  const shiftDirection = direction === "up" ? 1 : -1;
1687
+ const targetPanOffset = viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1);
1688
+ if (shiftDirection > 0 && viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1689
+ kind: "pan-left",
1690
+ targetPanOffset,
1691
+ })) {
1692
+ return;
1693
+ }
1370
1694
  const nextPan = clamp(
1371
- viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1),
1695
+ targetPanOffset,
1372
1696
  0,
1373
- getMaxPanOffset(baseHistory, viewState.timeRange, viewState.zoomLevel, chartWidth),
1697
+ getMaxPanOffset(baseHistory, viewState.zoomLevel),
1374
1698
  );
1375
- setViewState((current) => ({ ...current, panOffset: nextPan }));
1699
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1376
1700
  return;
1377
1701
  }
1378
1702
 
1379
1703
  switch (direction) {
1380
1704
  case "up":
1381
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, baseHistory, chartWidth));
1705
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, baseHistory));
1382
1706
  return;
1383
1707
  case "down":
1384
- setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, baseHistory, chartWidth));
1708
+ if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1709
+ kind: "zoom-out",
1710
+ targetVisibleCount: Math.round(baseHistory.length * 1.18),
1711
+ anchorRatio,
1712
+ })) {
1713
+ return;
1714
+ }
1715
+ setViewState((current) => applyZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, baseHistory));
1385
1716
  return;
1386
1717
  case "left":
1718
+ if (viewState.panOffset >= getMaxPanOffset(baseHistory, viewState.zoomLevel) && expandBufferRange({
1719
+ kind: "pan-left",
1720
+ targetPanOffset: viewState.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1721
+ })) {
1722
+ return;
1723
+ }
1387
1724
  setViewState((current) => ({
1388
- ...current,
1725
+ ...clearActivePreset(current),
1389
1726
  panOffset: clamp(
1390
1727
  current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1391
1728
  0,
1392
- getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth),
1729
+ getMaxPanOffset(baseHistory, current.zoomLevel),
1393
1730
  ),
1394
1731
  }));
1395
1732
  return;
1396
1733
  case "right":
1397
1734
  setViewState((current) => ({
1398
- ...current,
1735
+ ...clearActivePreset(current),
1399
1736
  panOffset: clamp(
1400
1737
  current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1401
1738
  0,
1402
- getMaxPanOffset(baseHistory, current.timeRange, current.zoomLevel, chartWidth),
1739
+ getMaxPanOffset(baseHistory, current.zoomLevel),
1403
1740
  ),
1404
1741
  }));
1405
1742
  return;
@@ -1420,17 +1757,29 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1420
1757
  height={chartHeight}
1421
1758
  flexDirection="column"
1422
1759
  backgroundColor={chartColors.bgColor}
1423
- onMouseMove={compact ? undefined : handlePlotMove}
1424
- onMouseDown={compact ? undefined : handlePlotDown}
1425
- onMouseUp={compact ? undefined : () => { dragRef.current = null; }}
1426
- onMouseDrag={compact ? undefined : handlePlotDrag}
1427
- onMouseDragEnd={compact ? undefined : () => { dragRef.current = null; }}
1428
- onMouseOut={compact ? undefined : () => {
1760
+ onMouseMove={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotMove}
1761
+ onMouseDown={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotDown}
1762
+ onMouseUp={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => { dragRef.current = null; }}
1763
+ onMouseDrag={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotDrag}
1764
+ onMouseDragEnd={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => { dragRef.current = null; }}
1765
+ onMouseOut={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : () => {
1429
1766
  dragRef.current = null;
1430
1767
  }}
1431
- onMouseScroll={compact ? undefined : handlePlotScroll}
1768
+ onMouseScroll={compact || !hasHistory || isBlockingBody || !!bodyMessage ? undefined : handlePlotScroll}
1432
1769
  >
1433
- {plotContent}
1770
+ {isBlockingBody
1771
+ ? (
1772
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1773
+ <text fg={colors.textDim}>Loading chart...</text>
1774
+ </box>
1775
+ )
1776
+ : bodyMessage
1777
+ ? (
1778
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1779
+ <text fg={colors.textDim}>{bodyMessage}</text>
1780
+ </box>
1781
+ )
1782
+ : plotContent}
1434
1783
  </box>
1435
1784
  );
1436
1785
 
@@ -1466,13 +1815,16 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1466
1815
  <box flexDirection="column" flexGrow={1}>
1467
1816
  <box flexDirection="row" gap={2} height={1}>
1468
1817
  <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1469
- {ticker?.metadata.ticker ?? ""} - {viewState.timeRange}
1818
+ {ticker?.metadata.ticker ?? ""} - {getChartResolutionLabel(effectiveResolution)}
1470
1819
  </text>
1471
- <text fg={priceColor(displayChange)}>
1472
- {formatCurrency(displayPrice, chartCurrency)}
1820
+ <text fg={colors.textDim}>{visibleLabel}</text>
1821
+ <text fg={displayChange !== null ? priceColor(displayChange) : colors.textDim}>
1822
+ {displayPrice !== null ? formatCurrency(displayPrice, chartCurrency) : "—"}
1473
1823
  </text>
1474
- <text fg={priceColor(displayChange)}>
1475
- {displayChange >= 0 ? "+" : ""}{displayChange.toFixed(2)} ({displayChangePct >= 0 ? "+" : ""}{displayChangePct.toFixed(2)}%)
1824
+ <text fg={displayChange !== null ? priceColor(displayChange) : colors.textDim}>
1825
+ {displayChange !== null && displayChangePct !== null
1826
+ ? `${displayChange >= 0 ? "+" : ""}${displayChange.toFixed(2)} (${displayChangePct >= 0 ? "+" : ""}${displayChangePct.toFixed(2)}%)`
1827
+ : "No data"}
1476
1828
  </text>
1477
1829
  {displayDate && <text fg={colors.textDim}>{displayDate}</text>}
1478
1830
  {showOhlcSummary && activePoint && (
@@ -1491,15 +1843,35 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1491
1843
  {TIME_RANGES.map((range, index) => (
1492
1844
  <text
1493
1845
  key={range}
1494
- fg={viewState.timeRange === range ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1495
- attributes={viewState.timeRange === range ? TextAttributes.BOLD : 0}
1496
- onMouseDown={() => setRange(range)}
1846
+ fg={activePreset === range ? chartColors.activeRangeColor : (isRangePresetSupported(range, availableManualResolutions) ? chartColors.inactiveRangeColor : colors.textMuted)}
1847
+ attributes={activePreset === range ? TextAttributes.BOLD : 0}
1848
+ onMouseDown={() => {
1849
+ if (isRangePresetSupported(range, availableManualResolutions)) setRange(range);
1850
+ }}
1497
1851
  >
1498
1852
  {`${index + 1}:${range}`}
1499
1853
  </text>
1500
1854
  ))}
1855
+ </box>
1856
+ </box>
1857
+
1858
+ <box flexDirection="row" height={1}>
1859
+ <box flexDirection="row" gap={1}>
1860
+ {resolutionChips.map((resolution) => (
1861
+ <text
1862
+ key={resolution}
1863
+ fg={effectiveResolution === resolution ? chartColors.activeRangeColor : chartColors.inactiveRangeColor}
1864
+ attributes={effectiveResolution === resolution ? TextAttributes.BOLD : 0}
1865
+ onMouseDown={() => setResolution(resolution)}
1866
+ >
1867
+ {getChartResolutionLabel(resolution)}
1868
+ </text>
1869
+ ))}
1501
1870
  {viewState.zoomLevel !== 1 && (
1502
- <text fg={colors.textDim}> zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1871
+ <text fg={colors.textDim}>zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1872
+ )}
1873
+ {isUpdating && (
1874
+ <text fg={colors.textDim}>updating</text>
1503
1875
  )}
1504
1876
  </box>
1505
1877
  <box flexGrow={1} />
@@ -1547,10 +1919,10 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
1547
1919
  <box height={1}>
1548
1920
  <text fg={colors.textMuted}>
1549
1921
  {interactive
1550
- ? "mouse hover inspect drag pan wheel zoom ⇧wheel pan ←→ cursor ⇧←→ pan m mode 1-7 range v vol Esc exit"
1551
- : "Enter crosshair click chart to focus a/d pan +/- zoom m mode 1-7 range v volume 0 reset"}
1922
+ ? "mouse hover inspect drag pan wheel zoom ⇧wheel pan ←→ cursor ⇧←→ pan m mode r res 1-7 presets v vol 0 reset Esc exit"
1923
+ : "Enter crosshair click chart to focus a/d pan +/- zoom m mode r res 1-7 presets v volume 0 reset"}
1552
1924
  </text>
1553
1925
  </box>
1554
1926
  </box>
1555
1927
  );
1556
- }
1928
+ });