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