gloomberb 0.3.7 → 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 (133) hide show
  1. package/README.md +19 -8
  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-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,7 +1,9 @@
1
1
  import { TextAttributes, type BoxRenderable, type CliRenderer } from "@opentui/core";
2
2
  import { useKeyboard, useRenderer } from "@opentui/react";
3
3
  import { memo, useEffect, useMemo, useRef, useState } from "react";
4
- import { useAppState } from "../../state/app-context";
4
+ import { useChartQueries } from "../../market-data/hooks";
5
+ import { buildChartKey } from "../../market-data/selectors";
6
+ import { useAppState, usePaneSettingValue } from "../../state/app-context";
5
7
  import { blendHex, colors, getComparisonSeriesColor, priceColor } from "../../theme/colors";
6
8
  import type { BrokerContractRef } from "../../types/instrument";
7
9
  import type { PricePoint } from "../../types/financials";
@@ -13,6 +15,35 @@ import {
13
15
  getVisibleComparisonWindow,
14
16
  projectComparisonChartData,
15
17
  } from "./comparison-chart-data";
18
+ import { usePersistChartControlSelection } from "./chart-pane-settings";
19
+ import {
20
+ buildVisibleDateWindow,
21
+ clearActivePreset,
22
+ formatVisibleSpanLabel,
23
+ getCanonicalZoomLevel,
24
+ isCanonicalPresetViewport,
25
+ resolveChartBodyState,
26
+ resolvePresetSelection,
27
+ resolveResolutionSelection,
28
+ } from "./chart-controller";
29
+ import {
30
+ buildChartResolutionSupportMap,
31
+ clampTimeRangeToMaxRange,
32
+ DEFAULT_COMPARISON_CHART_RANGE_PRESET,
33
+ DEFAULT_COMPARISON_CHART_RESOLUTION,
34
+ DEFAULT_VISIBLE_MANUAL_CHART_RESOLUTIONS,
35
+ getChartResolutionLabel,
36
+ getNextBufferRange,
37
+ getPresetResolution,
38
+ getSupportMaxRange,
39
+ isRangePresetSupported,
40
+ intersectChartResolutionSupport,
41
+ normalizeChartResolutionSupport,
42
+ sortChartResolutions,
43
+ type ChartResolutionSupport,
44
+ type ManualChartResolution,
45
+ } from "./chart-resolution";
46
+ import { RIGHT_EDGE_ANCHOR_RATIO } from "./chart-viewport";
16
47
  import {
17
48
  buildComparisonChartScene,
18
49
  formatComparisonAxisValue,
@@ -29,9 +60,11 @@ import {
29
60
  TIME_RANGES,
30
61
  type ChartAxisMode,
31
62
  type ChartRendererPreference,
63
+ type ChartResolution,
32
64
  type ComparisonChartRenderMode,
33
65
  type ComparisonChartViewState,
34
66
  type ResolvedChartRenderer,
67
+ type TimeRange,
35
68
  } from "./chart-types";
36
69
  import {
37
70
  computeBitmapSize,
@@ -84,6 +117,11 @@ interface DragState {
84
117
  startPanOffset: number;
85
118
  }
86
119
 
120
+ type PendingExpansionAction =
121
+ | { kind: "zoom-out"; targetVisibleCount: number; anchorRatio: number }
122
+ | { kind: "pan-left"; targetPanOffset: number }
123
+ | null;
124
+
87
125
  interface ChartMouseEvent {
88
126
  x: number;
89
127
  y: number;
@@ -477,6 +515,22 @@ function resolveSelectionCursor(
477
515
  };
478
516
  }
479
517
 
518
+ function getUniqueSortedSeriesDates(series: Array<{ points: PricePoint[] }>): Date[] {
519
+ const byTimestamp = new Map<number, Date>();
520
+ for (const entry of series) {
521
+ for (const point of entry.points) {
522
+ const date = point.date instanceof Date ? point.date : new Date(point.date);
523
+ const timestamp = date.getTime();
524
+ if (!Number.isNaN(timestamp)) {
525
+ byTimestamp.set(timestamp, date);
526
+ }
527
+ }
528
+ }
529
+ return [...byTimestamp.entries()]
530
+ .sort((left, right) => left[0] - right[0])
531
+ .map(([, date]) => date);
532
+ }
533
+
480
534
  function ComparisonStockChartView({
481
535
  paneId,
482
536
  width,
@@ -491,8 +545,14 @@ function ComparisonStockChartView({
491
545
  }: ComparisonStockChartViewProps) {
492
546
  const renderer = useRenderer();
493
547
  const nativeSurfaceManager = useMemo(() => getNativeSurfaceManager(renderer), [renderer]);
548
+ const [storedRangePreset] = usePaneSettingValue("rangePreset", DEFAULT_COMPARISON_CHART_RANGE_PRESET);
549
+ const [storedResolution] = usePaneSettingValue<ChartResolution>("chartResolution", DEFAULT_COMPARISON_CHART_RESOLUTION);
550
+ const persistChartControls = usePersistChartControlSelection("rangePreset");
494
551
  const [viewState, setViewState] = useState<ComparisonChartViewState>({
495
- timeRange: "1Y",
552
+ presetRange: storedRangePreset,
553
+ bufferRange: storedRangePreset,
554
+ activePreset: storedRangePreset,
555
+ resolution: storedResolution,
496
556
  panOffset: 0,
497
557
  zoomLevel: 1,
498
558
  cursorX: null,
@@ -500,10 +560,9 @@ function ComparisonStockChartView({
500
560
  renderMode: getInitialComparisonMode(defaultRenderMode),
501
561
  selectedSymbol: symbols[0] ?? null,
502
562
  });
503
- const [remoteHistory, setRemoteHistory] = useState<Record<string, PricePoint[] | null>>({});
563
+ const [resolutionSupport, setResolutionSupport] = useState<ChartResolutionSupport[] | null>(null);
504
564
  const [kittySupport, setKittySupport] = useState<boolean | null>(() => getCachedKittySupport(renderer));
505
565
  const [displayCursor, setDisplayCursor] = useState<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
506
- const fetchIdRef = useRef(0);
507
566
  const plotRef = useRef<BoxRenderable | null>(null);
508
567
  const nativeBaseSurfaceIdRef = useRef(`comparison-chart-surface:${paneId}:base`);
509
568
  const nativeCrosshairSurfaceIdRef = useRef(`comparison-chart-surface:${paneId}:crosshair`);
@@ -515,11 +574,14 @@ function ComparisonStockChartView({
515
574
  const targetCursorRef = useRef<DisplayCursorState>(EMPTY_DISPLAY_CURSOR);
516
575
  const cursorMotionKindRef = useRef<ChartCursorMotionKind>("discrete");
517
576
  const animationFrameRef = useRef<number | null>(null);
577
+ const pendingCanonicalResetRef = useRef(1);
578
+ const appliedCanonicalResetRef = useRef(0);
579
+ const pendingExpansionRef = useRef<PendingExpansionAction>(null);
518
580
 
519
581
  const axisWidth = 11;
520
582
  const axisGap = 1;
521
583
  const headerRows = 1;
522
- const controlRows = 1;
584
+ const controlRows = 2;
523
585
  const timeAxisRows = 1;
524
586
  const helpRows = 1;
525
587
  const legendColumns = getLegendColumns(width);
@@ -545,14 +607,13 @@ function ComparisonStockChartView({
545
607
  ));
546
608
  };
547
609
 
548
- const symbolMetaKey = useMemo(() => symbolSources.map((source) => {
549
- return [
550
- source.symbol,
551
- source.exchange,
552
- source.brokerId ?? "",
553
- source.brokerInstanceId ?? "",
554
- ].join(":");
555
- }).join("|"), [symbolSources]);
610
+ const capabilityKey = useMemo(() => symbolSources.map((source) => [
611
+ source.symbol,
612
+ source.exchange,
613
+ source.brokerId ?? "",
614
+ source.brokerInstanceId ?? "",
615
+ source.instrument?.conId ?? "",
616
+ ].join(":")).join("|"), [symbolSources]);
556
617
 
557
618
  useEffect(() => {
558
619
  if (symbols.includes(viewState.selectedSymbol ?? "")) return;
@@ -562,6 +623,19 @@ function ComparisonStockChartView({
562
623
  }));
563
624
  }, [symbols, viewState.selectedSymbol]);
564
625
 
626
+ useEffect(() => {
627
+ pendingCanonicalResetRef.current += 1;
628
+ setViewState((current) => resolvePresetSelection(
629
+ {
630
+ ...current,
631
+ resolution: storedResolution,
632
+ },
633
+ storedRangePreset,
634
+ storedResolution === "auto" ? null : getSupportMaxRange(resolutionSupport ?? [], storedResolution),
635
+ ));
636
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
637
+ }, [resolutionSupport, storedRangePreset, storedResolution]);
638
+
565
639
  const commitDisplayCursor = (next: DisplayCursorState) => {
566
640
  displayCursorRef.current = next;
567
641
  setDisplayCursor((current) => (sameDisplayCursorState(current, next) ? current : next));
@@ -644,33 +718,145 @@ function ComparisonStockChartView({
644
718
 
645
719
  useEffect(() => {
646
720
  const provider = getSharedDataProvider();
647
- if (!provider || symbols.length === 0) {
648
- setRemoteHistory({});
721
+ if ((!provider?.getChartResolutionSupport && !provider?.getChartResolutionCapabilities) || symbolSources.length === 0) {
722
+ setResolutionSupport(null);
649
723
  return;
650
724
  }
651
725
 
652
- const id = ++fetchIdRef.current;
653
- setRemoteHistory({});
654
-
726
+ let cancelled = false;
727
+ setResolutionSupport(null);
655
728
  Promise.all(symbolSources.map(async (source) => {
656
729
  try {
657
- const points = await provider.getPriceHistory(source.symbol, source.exchange, viewState.timeRange, {
658
- brokerId: source.brokerId,
659
- brokerInstanceId: source.brokerInstanceId,
660
- instrument: source.instrument,
661
- });
662
- return [source.symbol, points] as const;
730
+ const support = provider.getChartResolutionSupport
731
+ ? await provider.getChartResolutionSupport(
732
+ source.symbol,
733
+ source.exchange,
734
+ {
735
+ brokerId: source.brokerId,
736
+ brokerInstanceId: source.brokerInstanceId,
737
+ instrument: source.instrument ?? null,
738
+ },
739
+ )
740
+ : normalizeChartResolutionSupport(
741
+ (await provider.getChartResolutionCapabilities?.(
742
+ source.symbol,
743
+ source.exchange,
744
+ {
745
+ brokerId: source.brokerId,
746
+ brokerInstanceId: source.brokerInstanceId,
747
+ instrument: source.instrument ?? null,
748
+ },
749
+ ) ?? []).map((resolution) => ({ resolution, maxRange: "ALL" })),
750
+ );
751
+ return support;
663
752
  } catch {
664
- return [source.symbol, null] as const;
753
+ return null;
754
+ }
755
+ })).then((supportSets) => {
756
+ if (!cancelled) {
757
+ setResolutionSupport(supportSets.some((support) => support === null) ? null : intersectChartResolutionSupport(supportSets));
665
758
  }
666
- })).then((entries) => {
667
- if (fetchIdRef.current !== id) return;
668
- setRemoteHistory(Object.fromEntries(entries));
669
759
  }).catch(() => {
670
- if (fetchIdRef.current !== id) return;
671
- setRemoteHistory({});
760
+ if (!cancelled) {
761
+ setResolutionSupport(null);
762
+ }
763
+ });
764
+
765
+ return () => {
766
+ cancelled = true;
767
+ };
768
+ }, [capabilityKey, symbolSources]);
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 = viewState.resolution !== "auto"
776
+ && resolutionSupport !== null
777
+ && !supportMap.has(viewState.resolution)
778
+ ? "auto"
779
+ : viewState.resolution;
780
+ const resolutionChips = useMemo(
781
+ () => sortChartResolutions(["auto", ...availableManualResolutions] as ChartResolution[]),
782
+ [availableManualResolutions],
783
+ );
784
+ const chartRequests = useMemo(() => (
785
+ symbolSources.map((source) => ({
786
+ instrument: {
787
+ symbol: source.symbol,
788
+ exchange: source.exchange,
789
+ brokerId: source.brokerId,
790
+ brokerInstanceId: source.brokerInstanceId,
791
+ instrument: source.instrument,
792
+ },
793
+ bufferRange: viewState.bufferRange,
794
+ granularity: effectiveResolution === "auto" ? "range" as const : "resolution" as const,
795
+ resolution: effectiveResolution === "auto" ? undefined : effectiveResolution,
796
+ }))
797
+ ), [effectiveResolution, symbolSources, viewState.bufferRange]);
798
+ const chartEntries = useChartQueries(chartRequests);
799
+ const entryStates = useMemo(() => chartRequests.map((request) => (
800
+ resolveChartBodyState(chartEntries.get(buildChartKey(request)), (value) => Array.isArray(value) && value.length > 0, "No chart data yet.")
801
+ )), [chartEntries, chartRequests]);
802
+ const hasSeriesData = entryStates.some((state) => !!state.data?.length);
803
+ const isBlockingBody = entryStates.some((state) => state.blocking) || (chartRequests.length > 0 && entryStates.length !== chartRequests.length);
804
+ const bodyMessage = hasSeriesData
805
+ ? null
806
+ : entryStates.find((state) => state.errorMessage)?.errorMessage
807
+ ?? entryStates.find((state) => state.emptyMessage)?.emptyMessage
808
+ ?? null;
809
+ const isUpdating = !isBlockingBody && entryStates.some((state) => state.updating);
810
+ const series = useMemo(() => symbolSources.map((source, index) => {
811
+ const request = chartRequests[index];
812
+ const history = request ? (chartEntries.get(buildChartKey(request))?.data ?? []) : [];
813
+ const color = getComparisonSeriesColor(index);
814
+ return {
815
+ symbol: source.symbol,
816
+ color,
817
+ fillColor: blendHex(colors.bg, color, 0.22),
818
+ currency: source.currency,
819
+ points: history,
820
+ };
821
+ }), [chartEntries, chartRequests, colors.bg, symbolSources]);
822
+ const seriesDates = useMemo(() => getUniqueSortedSeriesDates(series), [series]);
823
+ const visibleDateWindow = useMemo(() => buildVisibleDateWindow(seriesDates, viewState.panOffset, viewState.zoomLevel), [seriesDates, viewState.panOffset, viewState.zoomLevel]);
824
+ const activePreset = isCanonicalPresetViewport(seriesDates, {
825
+ activePreset: viewState.activePreset,
826
+ panOffset: viewState.panOffset,
827
+ zoomLevel: viewState.zoomLevel,
828
+ resolution: effectiveResolution,
829
+ }) ? viewState.activePreset : null;
830
+
831
+ useEffect(() => {
832
+ if (seriesDates.length === 0) return;
833
+ if (appliedCanonicalResetRef.current < pendingCanonicalResetRef.current) {
834
+ const canonicalZoom = getCanonicalZoomLevel(seriesDates, viewState.presetRange);
835
+ appliedCanonicalResetRef.current = pendingCanonicalResetRef.current;
836
+ setViewState((current) => (
837
+ current.zoomLevel === canonicalZoom && current.panOffset === 0
838
+ ? current
839
+ : { ...current, zoomLevel: canonicalZoom, panOffset: 0, cursorX: null, cursorY: null }
840
+ ));
841
+ return;
842
+ }
843
+ if (!pendingExpansionRef.current) return;
844
+ const pendingExpansion = pendingExpansionRef.current;
845
+ pendingExpansionRef.current = null;
846
+ setViewState((current) => {
847
+ if (pendingExpansion.kind === "zoom-out") {
848
+ const nextVisibleCount = Math.min(seriesDates.length, Math.max(pendingExpansion.targetVisibleCount, 1));
849
+ return {
850
+ ...current,
851
+ ...resolveAnchoredChartZoom(seriesDates.length, 1, 0, seriesDates.length / nextVisibleCount, pendingExpansion.anchorRatio),
852
+ };
853
+ }
854
+ return {
855
+ ...current,
856
+ panOffset: clamp(pendingExpansion.targetPanOffset, 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
857
+ };
672
858
  });
673
- }, [symbolMetaKey, symbolSources, symbols.length, viewState.timeRange]);
859
+ }, [chartWidth, series, seriesDates, viewState.presetRange]);
674
860
 
675
861
  useEffect(() => {
676
862
  const refreshSupport = () => setKittySupport(getCachedKittySupport(renderer));
@@ -696,19 +882,40 @@ function ComparisonStockChartView({
696
882
  };
697
883
  }, [preferredRenderer, renderer]);
698
884
 
699
- const series = useMemo(() => symbolSources.map((source, index) => {
700
- const history = remoteHistory[source.symbol] && remoteHistory[source.symbol]!.length > 0
701
- ? remoteHistory[source.symbol]!
702
- : source.priceHistory;
703
- const color = getComparisonSeriesColor(index);
704
- return {
705
- symbol: source.symbol,
706
- color,
707
- fillColor: blendHex(colors.bg, color, 0.22),
708
- currency: source.currency,
709
- points: history,
710
- };
711
- }), [colors.bg, remoteHistory, symbolSources]);
885
+ const expandBufferRange = (action: PendingExpansionAction): boolean => {
886
+ const nextCandidate = getNextBufferRange(viewState.bufferRange);
887
+ const nextBufferRange = effectiveResolution === "auto"
888
+ ? nextCandidate
889
+ : clampTimeRangeToMaxRange(nextCandidate, supportMap.get(effectiveResolution) ?? viewState.bufferRange);
890
+ if (nextBufferRange === viewState.bufferRange) return false;
891
+ pendingExpansionRef.current = action;
892
+ setViewState((current) => ({
893
+ ...clearActivePreset(current),
894
+ bufferRange: nextBufferRange,
895
+ cursorX: null,
896
+ cursorY: null,
897
+ }));
898
+ return true;
899
+ };
900
+
901
+ const setRangePreset = (range: TimeRange) => {
902
+ if (!isRangePresetSupported(range, effectiveResolutionSupport)) return;
903
+ const supportMaxRange = getSupportMaxRange(effectiveResolutionSupport, getPresetResolution(range));
904
+ persistChartControls(range, getPresetResolution(range));
905
+ pendingCanonicalResetRef.current += 1;
906
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
907
+ setViewState((current) => resolvePresetSelection(current, range, supportMaxRange));
908
+ };
909
+
910
+ const setResolution = (resolution: ChartResolution) => {
911
+ if (resolution !== "auto" && !availableManualResolutions.includes(resolution)) return;
912
+ const nextState = resolveResolutionSelection(viewState, resolution, supportMap, visibleDateWindow);
913
+ if (!nextState) return;
914
+ pendingCanonicalResetRef.current += 1;
915
+ persistChartControls(nextState.presetRange, resolution);
916
+ updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
917
+ setViewState(nextState);
918
+ };
712
919
 
713
920
  const visibleWindow = useMemo(() => (
714
921
  getVisibleComparisonWindow(series, viewState, chartWidth)
@@ -1015,15 +1222,33 @@ function ComparisonStockChartView({
1015
1222
 
1016
1223
  switch (event.name) {
1017
1224
  case "=":
1018
- setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel * 1.5, 0.5, series, chartWidth));
1225
+ setViewState((current) => applyComparisonZoomAroundAnchor(
1226
+ clearActivePreset(current),
1227
+ current.zoomLevel * 1.5,
1228
+ RIGHT_EDGE_ANCHOR_RATIO,
1229
+ series,
1230
+ ));
1019
1231
  return;
1020
1232
  case "-":
1021
- setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel / 1.5, 0.5, series, chartWidth));
1233
+ if (viewState.zoomLevel <= 1.001 && expandBufferRange({
1234
+ kind: "zoom-out",
1235
+ targetVisibleCount: Math.round(seriesDates.length * 1.5),
1236
+ anchorRatio: RIGHT_EDGE_ANCHOR_RATIO,
1237
+ })) {
1238
+ return;
1239
+ }
1240
+ setViewState((current) => applyComparisonZoomAroundAnchor(
1241
+ clearActivePreset(current),
1242
+ current.zoomLevel / 1.5,
1243
+ RIGHT_EDGE_ANCHOR_RATIO,
1244
+ series,
1245
+ ));
1022
1246
  return;
1023
1247
  case "0":
1248
+ pendingCanonicalResetRef.current += 1;
1024
1249
  updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1025
1250
  setViewState((current) => ({
1026
- ...current,
1251
+ ...(resolveResolutionSelection(current, effectiveResolution, supportMap, visibleDateWindow) ?? current),
1027
1252
  panOffset: 0,
1028
1253
  zoomLevel: 1,
1029
1254
  cursorX: null,
@@ -1031,15 +1256,21 @@ function ComparisonStockChartView({
1031
1256
  }));
1032
1257
  return;
1033
1258
  case "a":
1259
+ if (viewState.panOffset >= getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth) && expandBufferRange({
1260
+ kind: "pan-left",
1261
+ targetPanOffset: viewState.panOffset + Math.max(Math.floor(chartWidth / 10), 1),
1262
+ })) {
1263
+ return;
1264
+ }
1034
1265
  setViewState((current) => ({
1035
- ...current,
1036
- panOffset: clamp(current.panOffset + Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth)),
1266
+ ...clearActivePreset(current),
1267
+ panOffset: clamp(current.panOffset + Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
1037
1268
  }));
1038
1269
  return;
1039
1270
  case "d":
1040
1271
  setViewState((current) => ({
1041
- ...current,
1042
- panOffset: clamp(current.panOffset - Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth)),
1272
+ ...clearActivePreset(current),
1273
+ panOffset: clamp(current.panOffset - Math.max(Math.floor(chartWidth / 10), 1), 0, getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth)),
1043
1274
  }));
1044
1275
  return;
1045
1276
  case "m":
@@ -1048,6 +1279,12 @@ function ComparisonStockChartView({
1048
1279
  renderMode: current.renderMode === "line" ? "area" : "line",
1049
1280
  }));
1050
1281
  return;
1282
+ case "r": {
1283
+ const currentIndex = resolutionChips.indexOf(effectiveResolution);
1284
+ const nextResolution = resolutionChips[(currentIndex + 1) % resolutionChips.length] ?? "auto";
1285
+ setResolution(nextResolution);
1286
+ return;
1287
+ }
1051
1288
  case "h":
1052
1289
  cursorMotionKindRef.current = "discrete";
1053
1290
  setViewState((current) => ({
@@ -1113,15 +1350,7 @@ function ComparisonStockChartView({
1113
1350
  if (event.name >= "1" && event.name <= "7") {
1114
1351
  const index = parseInt(event.name, 10) - 1;
1115
1352
  if (index < TIME_RANGES.length) {
1116
- updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1117
- setViewState((current) => ({
1118
- ...current,
1119
- timeRange: TIME_RANGES[index]!,
1120
- panOffset: 0,
1121
- zoomLevel: 1,
1122
- cursorX: null,
1123
- cursorY: null,
1124
- }));
1353
+ setRangePreset(TIME_RANGES[index]!);
1125
1354
  }
1126
1355
  }
1127
1356
  });
@@ -1192,7 +1421,7 @@ function ComparisonStockChartView({
1192
1421
  0,
1193
1422
  Math.max(visibleWindow.totalDates - visibleWindow.dates.length, 0),
1194
1423
  );
1195
- setViewState((current) => ({ ...current, panOffset: nextPan }));
1424
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1196
1425
  };
1197
1426
 
1198
1427
  const handlePlotScroll = (event: ChartMouseEvent) => {
@@ -1220,39 +1449,59 @@ function ComparisonStockChartView({
1220
1449
 
1221
1450
  if (event.modifiers.shift && (direction === "up" || direction === "down")) {
1222
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
+ }
1223
1459
  const nextPan = clamp(
1224
- viewState.panOffset + shiftDirection * Math.max(Math.round(chartWidth * 0.08), 1),
1460
+ targetPanOffset,
1225
1461
  0,
1226
- getMaxComparisonPanOffset(series, viewState.timeRange, viewState.zoomLevel, chartWidth),
1462
+ getMaxComparisonPanOffset(series, viewState.presetRange, viewState.zoomLevel, chartWidth),
1227
1463
  );
1228
- setViewState((current) => ({ ...current, panOffset: nextPan }));
1464
+ setViewState((current) => ({ ...clearActivePreset(current), panOffset: nextPan }));
1229
1465
  return;
1230
1466
  }
1231
1467
 
1232
1468
  switch (direction) {
1233
1469
  case "up":
1234
- setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel * 1.18, anchorRatio, series, chartWidth));
1470
+ setViewState((current) => applyComparisonZoomAroundAnchor(clearActivePreset(current), current.zoomLevel * 1.18, anchorRatio, series));
1235
1471
  return;
1236
1472
  case "down":
1237
- setViewState((current) => applyComparisonZoomAroundAnchor(current, current.zoomLevel / 1.18, anchorRatio, series, chartWidth));
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));
1238
1481
  return;
1239
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
+ }
1240
1489
  setViewState((current) => ({
1241
- ...current,
1490
+ ...clearActivePreset(current),
1242
1491
  panOffset: clamp(
1243
1492
  current.panOffset + Math.max(Math.round(chartWidth * 0.08), 1),
1244
1493
  0,
1245
- getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth),
1494
+ getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth),
1246
1495
  ),
1247
1496
  }));
1248
1497
  return;
1249
1498
  case "right":
1250
1499
  setViewState((current) => ({
1251
- ...current,
1500
+ ...clearActivePreset(current),
1252
1501
  panOffset: clamp(
1253
1502
  current.panOffset - Math.max(Math.round(chartWidth * 0.08), 1),
1254
1503
  0,
1255
- getMaxComparisonPanOffset(series, current.timeRange, current.zoomLevel, chartWidth),
1504
+ getMaxComparisonPanOffset(series, current.presetRange, current.zoomLevel, chartWidth),
1256
1505
  ),
1257
1506
  }));
1258
1507
  return;
@@ -1263,12 +1512,9 @@ function ComparisonStockChartView({
1263
1512
  return <text fg={colors.textDim}>No comparison tickers configured.</text>;
1264
1513
  }
1265
1514
 
1266
- if (series.every((entry) => entry.points.length === 0)) {
1267
- return <text fg={colors.textDim}>No chart data yet.</text>;
1268
- }
1269
-
1270
- const selectedSeries = result.selectedSeries ?? staticResult.selectedSeries;
1271
- const selectedPoint = result.selectedPoint ?? staticResult.selectedPoint;
1515
+ const hasChartData = series.some((entry) => entry.points.length > 0);
1516
+ const selectedSeries = hasChartData ? (result.selectedSeries ?? staticResult.selectedSeries) : null;
1517
+ const selectedPoint = hasChartData ? (result.selectedPoint ?? staticResult.selectedPoint) : null;
1272
1518
  const selectedRawValue = selectedPoint?.rawValue ?? selectedSeries?.latestRawValue ?? null;
1273
1519
  const selectedBaseValue = selectedSeries?.baseValue ?? null;
1274
1520
  const selectedChange = selectedRawValue !== null && selectedBaseValue !== null
@@ -1283,6 +1529,10 @@ function ComparisonStockChartView({
1283
1529
  const cursorAxisLabel = result.cursorRow !== null && result.crosshairValue !== null
1284
1530
  ? formatComparisonAxisValue(result.crosshairValue, projection.effectiveAxisMode)
1285
1531
  : null;
1532
+ const visibleLabel = formatVisibleSpanLabel({
1533
+ start: visibleWindow.dates[0] ?? null,
1534
+ end: visibleWindow.dates[visibleWindow.dates.length - 1] ?? null,
1535
+ });
1286
1536
 
1287
1537
  const plotLines: Array<string | StyledContent> = effectiveRenderer === "kitty"
1288
1538
  ? blankPlotLines
@@ -1298,17 +1548,29 @@ function ComparisonStockChartView({
1298
1548
  height={chartHeight}
1299
1549
  flexDirection="column"
1300
1550
  backgroundColor={chartColors.bgColor}
1301
- onMouseMove={handlePlotMove}
1302
- onMouseDown={handlePlotDown}
1303
- onMouseUp={() => { dragRef.current = null; }}
1304
- onMouseDrag={handlePlotDrag}
1305
- onMouseDragEnd={() => { dragRef.current = null; }}
1306
- onMouseOut={() => {
1551
+ onMouseMove={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotMove : undefined}
1552
+ onMouseDown={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotDown : undefined}
1553
+ onMouseUp={hasChartData && !isBlockingBody && !bodyMessage ? () => { dragRef.current = null; } : undefined}
1554
+ onMouseDrag={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotDrag : undefined}
1555
+ onMouseDragEnd={hasChartData && !isBlockingBody && !bodyMessage ? () => { dragRef.current = null; } : undefined}
1556
+ onMouseOut={hasChartData && !isBlockingBody && !bodyMessage ? () => {
1307
1557
  dragRef.current = null;
1308
- }}
1309
- onMouseScroll={handlePlotScroll}
1558
+ } : undefined}
1559
+ onMouseScroll={hasChartData && !isBlockingBody && !bodyMessage ? handlePlotScroll : undefined}
1310
1560
  >
1311
- {plotContent}
1561
+ {isBlockingBody
1562
+ ? (
1563
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1564
+ <text fg={colors.textDim}>Loading chart...</text>
1565
+ </box>
1566
+ )
1567
+ : bodyMessage
1568
+ ? (
1569
+ <box flexGrow={1} alignItems="center" justifyContent="center">
1570
+ <text fg={colors.textDim}>{bodyMessage}</text>
1571
+ </box>
1572
+ )
1573
+ : plotContent}
1312
1574
  </box>
1313
1575
  );
1314
1576
 
@@ -1334,8 +1596,9 @@ function ComparisonStockChartView({
1334
1596
  <box flexDirection="column" flexGrow={1}>
1335
1597
  <box flexDirection="row" gap={2} height={1}>
1336
1598
  <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
1337
- {viewState.selectedSymbol ?? "Compare"} - {viewState.timeRange}
1599
+ {viewState.selectedSymbol ?? "Compare"} - {getChartResolutionLabel(effectiveResolution)}
1338
1600
  </text>
1601
+ <text fg={colors.textDim}>{visibleLabel}</text>
1339
1602
  <text fg={selectedChange !== null ? priceColor(selectedChange) : colors.textDim}>
1340
1603
  {selectedRawValue !== null ? formatCurrency(selectedRawValue, selectedCurrency) : "—"}
1341
1604
  </text>
@@ -1352,25 +1615,37 @@ function ComparisonStockChartView({
1352
1615
  {TIME_RANGES.map((range, index) => (
1353
1616
  <text
1354
1617
  key={range}
1355
- fg={viewState.timeRange === range ? colors.textBright : colors.textDim}
1356
- attributes={viewState.timeRange === range ? TextAttributes.BOLD : 0}
1618
+ fg={activePreset === range ? colors.textBright : (isRangePresetSupported(range, availableManualResolutions) ? colors.textDim : colors.textMuted)}
1619
+ attributes={activePreset === range ? TextAttributes.BOLD : 0}
1357
1620
  onMouseDown={() => {
1358
- updateDisplayCursorTarget(EMPTY_DISPLAY_CURSOR, "discrete");
1359
- setViewState((current) => ({
1360
- ...current,
1361
- timeRange: range,
1362
- panOffset: 0,
1363
- zoomLevel: 1,
1364
- cursorX: null,
1365
- cursorY: null,
1366
- }));
1621
+ if (isRangePresetSupported(range, availableManualResolutions)) {
1622
+ setRangePreset(range);
1623
+ }
1367
1624
  }}
1368
1625
  >
1369
1626
  {`${index + 1}:${range}`}
1370
1627
  </text>
1371
1628
  ))}
1629
+ </box>
1630
+ </box>
1631
+
1632
+ <box flexDirection="row" height={1}>
1633
+ <box flexDirection="row" gap={1}>
1634
+ {resolutionChips.map((resolution) => (
1635
+ <text
1636
+ key={resolution}
1637
+ fg={effectiveResolution === resolution ? colors.textBright : colors.textDim}
1638
+ attributes={effectiveResolution === resolution ? TextAttributes.BOLD : 0}
1639
+ onMouseDown={() => setResolution(resolution)}
1640
+ >
1641
+ {getChartResolutionLabel(resolution)}
1642
+ </text>
1643
+ ))}
1372
1644
  {viewState.zoomLevel !== 1 && (
1373
- <text fg={colors.textDim}> zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1645
+ <text fg={colors.textDim}>zoom:{viewState.zoomLevel.toFixed(1)}x</text>
1646
+ )}
1647
+ {isUpdating && (
1648
+ <text fg={colors.textDim}>updating</text>
1374
1649
  )}
1375
1650
  </box>
1376
1651
  <box flexGrow={1} />
@@ -1446,7 +1721,7 @@ function ComparisonStockChartView({
1446
1721
 
1447
1722
  <box height={1}>
1448
1723
  <text fg={colors.textMuted}>
1449
- mouse hover inspect drag pan wheel zoom ⇧wheel pan h/l cursor arrows legend Enter open 1-7 range m mode 0 reset
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
1450
1725
  </text>
1451
1726
  </box>
1452
1727
  </box>