gloomberb 0.3.2 → 0.3.7

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 CHANGED
@@ -25,20 +25,16 @@ For the best experience, use a [Kitty](https://sw.kovidgoyal.net/kitty/)-compati
25
25
  ## 🚀 Install
26
26
 
27
27
  ```bash
28
- bun install -g gloomberb
29
- # or
30
28
  curl -fsSL gloomberb.com/install | bash
29
+ # or
30
+ bun install -g gloomberb
31
31
  ```
32
32
 
33
- Then run `gloomberb` to start.
34
-
35
- When developing from source, launch with a real Bun runtime, for example `bun src/index.tsx`.
36
- If `bun --help` prints Gloomberb help instead of Bun help, your Bun binary has been overwritten and needs to be reinstalled.
37
- In-app self-updates are disabled while running from source so Gloomberb does not replace the Bun executable.
33
+ Then run `gloomberb`.
38
34
 
39
35
  ## 🧩 Plugins
40
36
 
41
- Gloomberb has a plugin architecture where everything, from the portfolio list to broker integrations, is a plugin.
37
+ Everything, from the portfolio list to broker integrations, is a plugin.
42
38
 
43
39
  Plugins can add tabs, columns, commands, status bar widgets, and more.
44
40
 
@@ -98,3 +94,7 @@ Toggleable plugins can be enabled/disabled from the command bar screen (`Ctrl+p`
98
94
  ## License
99
95
 
100
96
  MIT
97
+
98
+ ## Credits
99
+
100
+ - [OpenTUI](https://opentui.com/) for the layout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.3.2",
3
+ "version": "0.3.7",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -917,6 +917,7 @@ export interface RenderChartOptions {
917
917
  axisMode?: ChartAxisMode;
918
918
  currency?: string;
919
919
  colors: ResolvedChartPalette;
920
+ timeAxisDates?: Array<Date | string | number>;
920
921
  }
921
922
 
922
923
  export interface ChartScene {
@@ -1027,6 +1028,8 @@ export function buildChartScene(
1027
1028
  ? null
1028
1029
  : max - (cursorY / Math.max(chartRows - 1, 1)) * range;
1029
1030
 
1031
+ const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
1032
+
1030
1033
  return {
1031
1034
  points,
1032
1035
  width: opts.width,
@@ -1045,7 +1048,7 @@ export function buildChartScene(
1045
1048
  dateAtCursor: activePoint.date,
1046
1049
  changeAtCursor: activePoint.close - points[0]!.close,
1047
1050
  changePctAtCursor: points[0]!.close ? ((activePoint.close - points[0]!.close) / points[0]!.close) * 100 : 0,
1048
- timeLabels: buildTimeAxis(points.map((point) => point.date), opts.width),
1051
+ timeLabels: buildTimeAxis(timeAxisDates, opts.width),
1049
1052
  cursorX,
1050
1053
  cursorY,
1051
1054
  cursorColumn,
@@ -1138,10 +1141,12 @@ export function renderChart(
1138
1141
  );
1139
1142
  }
1140
1143
 
1144
+ const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
1145
+
1141
1146
  return {
1142
1147
  lines: bufferToBrailleLines(buf, palette.bgColor),
1143
1148
  axisLabels: [...axisLabelsByRow.entries()].map(([row, label]) => ({ row, label })),
1144
- timeLabels: buildTimeAxis(points.map((point) => point.date), width),
1149
+ timeLabels: buildTimeAxis(timeAxisDates, width),
1145
1150
  activePoint,
1146
1151
  priceAtCursor,
1147
1152
  crosshairPrice,
@@ -206,6 +206,32 @@ describe("renderChart", () => {
206
206
  expect(buildTimeAxis(yearlyDates, 48)).toBe("2020 2021 2022 2023 2024 2025");
207
207
  });
208
208
 
209
+ test("uses source time-axis dates instead of projected extrema dates when provided", () => {
210
+ const sourceHistory = Array.from({ length: 96 }, (_, index) => ({
211
+ date: new Date(Date.UTC(2026, 2, 25, index * 3)),
212
+ close: index % 2 === 0 ? 0.7 + index * 0.001 : 0.3 + index * 0.001,
213
+ volume: 100 + index,
214
+ })) as PricePoint[];
215
+ const sourceDates = sourceHistory.map((point) => point.date);
216
+ const projection = projectChartData(sourceHistory, 48, "area", true);
217
+
218
+ const result = renderChart(projection.points, {
219
+ width: 48,
220
+ height: 8,
221
+ showVolume: false,
222
+ volumeHeight: 0,
223
+ cursorX: null,
224
+ cursorY: null,
225
+ mode: "area",
226
+ axisMode: "price",
227
+ currency: "USD",
228
+ colors: palette,
229
+ timeAxisDates: sourceDates,
230
+ });
231
+
232
+ expect(result.timeLabels).toBe(buildTimeAxis(sourceDates, 48));
233
+ });
234
+
209
235
  test("maps active bucket metadata across all render modes", () => {
210
236
  const cases: Array<{ mode: ChartRenderMode; width: number; cursorX: number }> = [
211
237
  { mode: "area", width: 12, cursorX: 4 },
@@ -69,6 +69,8 @@ interface StockChartProps {
69
69
  interactive?: boolean;
70
70
  compact?: boolean;
71
71
  axisMode?: ChartAxisMode;
72
+ historyOverride?: PricePoint[] | null;
73
+ currencyOverride?: string | null;
72
74
  }
73
75
 
74
76
  interface DragState {
@@ -505,7 +507,16 @@ function resolveSelectionCursor(
505
507
  };
506
508
  }
507
509
 
508
- export function StockChart({ width, height, focused, interactive, compact, axisMode = "price" }: StockChartProps) {
510
+ export function StockChart({
511
+ width,
512
+ height,
513
+ focused,
514
+ interactive,
515
+ compact,
516
+ axisMode = "price",
517
+ historyOverride = null,
518
+ currencyOverride = null,
519
+ }: StockChartProps) {
509
520
  const renderer = useRenderer();
510
521
  const { state, dispatch } = useAppState();
511
522
  const paneId = usePaneInstanceId();
@@ -637,9 +648,10 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
637
648
  const chartWidth = Math.max(width - axisWidth - axisGap, compact ? 12 : 20);
638
649
  const maxCursorX = chartWidth - 1;
639
650
  const panStep = Math.max(Math.floor(chartWidth / 10), 1);
640
- const baseHistory = rangeHistory && rangeHistory.length > 0
641
- ? rangeHistory
642
- : (financials?.priceHistory ?? []);
651
+ const baseHistory = historyOverride
652
+ ?? (rangeHistory && rangeHistory.length > 0
653
+ ? rangeHistory
654
+ : (financials?.priceHistory ?? []));
643
655
  const detailRequest = useMemo(() => {
644
656
  if (compact || !instrumentRef || baseHistory.length < 2 || viewState.zoomLevel <= 1) return null;
645
657
  const window = getVisibleWindow(baseHistory, viewState, chartWidth);
@@ -751,11 +763,18 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
751
763
  queueMicrotask(() => renderer.requestRender());
752
764
  }, [chartHeight, chartWidth, compact, historyRenderKey, renderer, ticker?.metadata.ticker, viewState.renderMode]);
753
765
 
754
- const chartWindow = useMemo(() => (
755
- isDetailView
766
+ const chartWindow = useMemo(() => {
767
+ if (historyOverride) {
768
+ return { points: history, startIdx: 0, endIdx: history.length };
769
+ }
770
+ return isDetailView
756
771
  ? { points: history, startIdx: 0, endIdx: history.length }
757
- : getVisibleWindow(history, viewState, chartWidth)
758
- ), [chartWidth, history, isDetailView, viewState.panOffset, viewState.timeRange, viewState.zoomLevel]);
772
+ : getVisibleWindow(history, viewState, chartWidth);
773
+ }, [chartWidth, history, historyOverride, isDetailView, viewState.panOffset, viewState.timeRange, viewState.zoomLevel]);
774
+ const timeAxisDates = useMemo(
775
+ () => chartWindow.points.map((point) => point.date),
776
+ [chartWindow.points],
777
+ );
759
778
 
760
779
  const projection = useMemo(() => (
761
780
  projectChartData(chartWindow.points, chartWidth, viewState.renderMode, !!compact)
@@ -891,7 +910,7 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
891
910
  negative: colors.negative,
892
911
  }, trend);
893
912
  }, [chartWindow.points]);
894
- const chartCurrency = financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
913
+ const chartCurrency = currencyOverride ?? financials?.quote?.currency ?? ticker?.metadata.currency ?? "USD";
895
914
 
896
915
  const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
897
916
  const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
@@ -918,7 +937,8 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
918
937
  mode: projection.effectiveMode,
919
938
  axisMode,
920
939
  colors: chartColors,
921
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, volumeHeight]);
940
+ timeAxisDates,
941
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, timeAxisDates, volumeHeight]);
922
942
  const snappedSelectionCursorX = selectionScene
923
943
  ? getPointTerminalColumn(selectionScene.activeIdx, projection.points.length, chartWidth, projection.effectiveMode)
924
944
  : null;
@@ -962,7 +982,8 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
962
982
  mode: projection.effectiveMode,
963
983
  axisMode,
964
984
  colors: chartColors,
965
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
985
+ timeAxisDates,
986
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
966
987
 
967
988
  const rendererState = resolveChartRendererState(preferredRenderer, kittySupport, renderer.resolution);
968
989
  const effectiveRenderer: ResolvedChartRenderer = rendererState.renderer;
@@ -978,7 +999,8 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
978
999
  axisMode,
979
1000
  currency: chartCurrency,
980
1001
  colors: chartColors,
981
- }), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
1002
+ timeAxisDates,
1003
+ }), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
982
1004
 
983
1005
  const interactiveResult = useMemo(() => (
984
1006
  effectiveRenderer === "kitty"
@@ -994,8 +1016,9 @@ export function StockChart({ width, height, focused, interactive, compact, axisM
994
1016
  axisMode,
995
1017
  currency: chartCurrency,
996
1018
  colors: chartColors,
1019
+ timeAxisDates,
997
1020
  })
998
- ), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, projection.effectiveMode, projection.points, showVolume, volumeHeight]);
1021
+ ), [axisMode, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
999
1022
 
1000
1023
  const result = effectiveRenderer === "kitty" ? staticResult : interactiveResult!;
1001
1024
 
@@ -6,6 +6,7 @@ import {
6
6
  applyTickByTickBidAskToQuote,
7
7
  diagnoseLocalIbkrPortIssue,
8
8
  IbkrGatewayService,
9
+ parseIbkrHistoricalBarTime,
9
10
  resolveGatewayConnection,
10
11
  summarizeBrokerAccount,
11
12
  } from "./gateway-service";
@@ -239,6 +240,35 @@ describe("tick-by-tick quote updates", () => {
239
240
  });
240
241
  });
241
242
 
243
+ describe("parseIbkrHistoricalBarTime", () => {
244
+ test("parses compact day bars without treating them as epoch seconds", () => {
245
+ const date = parseIbkrHistoricalBarTime(20250328);
246
+
247
+ expect(Number.isNaN(date.getTime())).toBe(false);
248
+ expect(date.getFullYear()).toBe(2025);
249
+ expect(date.getMonth()).toBe(2);
250
+ expect(date.getDate()).toBe(28);
251
+ expect(date.getHours()).toBe(0);
252
+ expect(date.getMinutes()).toBe(0);
253
+ });
254
+
255
+ test("parses compact intraday bar timestamps", () => {
256
+ const date = parseIbkrHistoricalBarTime("20250328 09:30:00");
257
+
258
+ expect(Number.isNaN(date.getTime())).toBe(false);
259
+ expect(date.getFullYear()).toBe(2025);
260
+ expect(date.getMonth()).toBe(2);
261
+ expect(date.getDate()).toBe(28);
262
+ expect(date.getHours()).toBe(9);
263
+ expect(date.getMinutes()).toBe(30);
264
+ expect(date.getSeconds()).toBe(0);
265
+ });
266
+
267
+ test("preserves epoch-second timestamps for streaming-style bars", () => {
268
+ expect(parseIbkrHistoricalBarTime(1_700_000_000).getTime()).toBe(1_700_000_000_000);
269
+ });
270
+ });
271
+
242
272
  describe("diagnoseLocalIbkrPortIssue", () => {
243
273
  test("reports a detected local IBKR listener on a different port", async () => {
244
274
  const issue = await diagnoseLocalIbkrPortIssue({
@@ -310,6 +310,42 @@ const GENERIC_BAR_SIZE_MAP: Record<string, BarSizeSetting> = {
310
310
  "1w": BarSizeSetting.WEEKS_ONE,
311
311
  };
312
312
 
313
+ export function parseIbkrHistoricalBarTime(value: string | number): Date {
314
+ if (typeof value === "number") {
315
+ if (!Number.isFinite(value)) return new Date(Number.NaN);
316
+
317
+ const compactDate = String(Math.trunc(value));
318
+ if (/^\d{8}$/.test(compactDate)) {
319
+ return parseIbkrHistoricalBarTime(compactDate);
320
+ }
321
+
322
+ return new Date(value > 10_000_000_000 ? value : value * 1000);
323
+ }
324
+
325
+ const trimmed = value.trim();
326
+ if (!trimmed) return new Date(Number.NaN);
327
+
328
+ const parsed = Date.parse(trimmed);
329
+ if (Number.isFinite(parsed)) {
330
+ return new Date(parsed);
331
+ }
332
+
333
+ const compactMatch = trimmed.match(/^(\d{4})(\d{2})(\d{2})(?:\D+(\d{2}):(\d{2}):(\d{2}))?/);
334
+ if (!compactMatch) {
335
+ return new Date(Number.NaN);
336
+ }
337
+
338
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = compactMatch;
339
+ const year = Number(yearText);
340
+ const month = Number(monthText) - 1;
341
+ const day = Number(dayText);
342
+ const hour = Number(hourText ?? "0");
343
+ const minute = Number(minuteText ?? "0");
344
+ const second = Number(secondText ?? "0");
345
+
346
+ return new Date(year, month, day, hour, minute, second);
347
+ }
348
+
313
349
  function normalizeQuoteStreamTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
314
350
  const symbol = target.symbol.trim().toUpperCase();
315
351
  if (!symbol) return null;
@@ -1006,7 +1042,7 @@ export class IbkrGatewayService {
1006
1042
  const priceDivisor = getIbkrPriceDivisor(contract, details);
1007
1043
 
1008
1044
  return bars.map((bar) => ({
1009
- date: new Date(typeof bar.time === "number" ? bar.time * 1000 : Date.parse(String(bar.time))),
1045
+ date: parseIbkrHistoricalBarTime(bar.time),
1010
1046
  open: normalizeIbkrPriceValue(bar.open, priceDivisor),
1011
1047
  high: normalizeIbkrPriceValue(bar.high, priceDivisor),
1012
1048
  low: normalizeIbkrPriceValue(bar.low, priceDivisor),
@@ -1064,7 +1100,7 @@ export class IbkrGatewayService {
1064
1100
  const priceDivisor = getIbkrPriceDivisor(contract, details);
1065
1101
 
1066
1102
  return bars.map((bar) => ({
1067
- date: new Date(typeof bar.time === "number" ? bar.time * 1000 : Date.parse(String(bar.time))),
1103
+ date: parseIbkrHistoricalBarTime(bar.time),
1068
1104
  open: normalizeIbkrPriceValue(bar.open, priceDivisor),
1069
1105
  high: normalizeIbkrPriceValue(bar.high, priceDivisor),
1070
1106
  low: normalizeIbkrPriceValue(bar.low, priceDivisor),
@@ -0,0 +1,139 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import { createTestRenderer } from "@opentui/core/testing";
4
+ import { createRoot } from "@opentui/react";
5
+ import { act, useEffect, useReducer, useRef } from "react";
6
+ import {
7
+ AppContext,
8
+ PaneInstanceProvider,
9
+ appReducer,
10
+ createInitialState,
11
+ } from "../../state/app-context";
12
+ import { createDefaultConfig } from "../../types/config";
13
+ import { getNativeSurfaceManager } from "../../components/chart/native/surface-manager";
14
+ import { PredictionMarketChart } from "./chart";
15
+
16
+ const TEST_PANE_ID = "prediction-scroll:test";
17
+ const SURFACE_ID = `chart-surface:${TEST_PANE_ID}:compact:base`;
18
+
19
+ let testSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;
20
+ let root: ReturnType<typeof createRoot> | undefined;
21
+ let scrollBoxRef: ScrollBoxRenderable | null = null;
22
+ const actEnvironment = globalThis as typeof globalThis & {
23
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
24
+ };
25
+
26
+ function ChartScrollHarness() {
27
+ const [state, dispatch] = useReducer(
28
+ appReducer,
29
+ (() => {
30
+ const config = createDefaultConfig("/tmp/gloomberb-test");
31
+ config.chartPreferences.renderer = "kitty";
32
+ const initial = createInitialState(config);
33
+ initial.focusedPaneId = TEST_PANE_ID;
34
+ return initial;
35
+ })(),
36
+ );
37
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
38
+
39
+ useEffect(() => {
40
+ scrollBoxRef = scrollRef.current;
41
+ });
42
+
43
+ return (
44
+ <AppContext value={{ state, dispatch }}>
45
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
46
+ <scrollbox ref={scrollRef} height={10} scrollY>
47
+ <box flexDirection="column">
48
+ <box height={14}>
49
+ <text>filler</text>
50
+ </box>
51
+ <PredictionMarketChart
52
+ history={[
53
+ { date: new Date("2026-04-01T00:00:00Z"), close: 0.45 },
54
+ { date: new Date("2026-04-02T00:00:00Z"), close: 0.48 },
55
+ { date: new Date("2026-04-03T00:00:00Z"), close: 0.51 },
56
+ { date: new Date("2026-04-04T00:00:00Z"), close: 0.49 },
57
+ ]}
58
+ width={60}
59
+ height={12}
60
+ range="1M"
61
+ onRangeSelect={() => {}}
62
+ />
63
+ </box>
64
+ </scrollbox>
65
+ </PaneInstanceProvider>
66
+ </AppContext>
67
+ );
68
+ }
69
+
70
+ async function flushFrames(count = 4) {
71
+ for (let index = 0; index < count; index += 1) {
72
+ await act(async () => {
73
+ await new Promise((resolve) => setTimeout(resolve, 0));
74
+ await Promise.resolve();
75
+ await testSetup!.renderOnce();
76
+ });
77
+ }
78
+ }
79
+
80
+ afterEach(() => {
81
+ scrollBoxRef = null;
82
+ if (root) {
83
+ act(() => {
84
+ root!.unmount();
85
+ });
86
+ root = undefined;
87
+ }
88
+ if (testSetup) {
89
+ testSetup.renderer.destroy();
90
+ testSetup = undefined;
91
+ }
92
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = false;
93
+ });
94
+
95
+ describe("PredictionMarketChart kitty scrolling", () => {
96
+ test("creates a native chart surface when scrolled into view", async () => {
97
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
98
+ testSetup = await createTestRenderer({ width: 100, height: 24 });
99
+ (testSetup.renderer as { _capabilities: unknown })._capabilities = {
100
+ kitty_graphics: true,
101
+ };
102
+ (testSetup.renderer as { _resolution: unknown })._resolution = {
103
+ width: 1000,
104
+ height: 720,
105
+ };
106
+
107
+ root = createRoot(testSetup.renderer);
108
+ act(() => {
109
+ root!.render(<ChartScrollHarness />);
110
+ });
111
+
112
+ await flushFrames();
113
+
114
+ const manager = getNativeSurfaceManager(testSetup.renderer as never) as unknown as {
115
+ surfaces: Map<
116
+ string,
117
+ {
118
+ snapshot: {
119
+ visibleRect: { x: number; y: number; width: number; height: number } | null;
120
+ };
121
+ }
122
+ >;
123
+ };
124
+
125
+ const hiddenSurface = manager.surfaces.get(SURFACE_ID);
126
+ expect(hiddenSurface).toBeUndefined();
127
+
128
+ act(() => {
129
+ scrollBoxRef!.scrollTop = 14;
130
+ });
131
+
132
+ await flushFrames();
133
+
134
+ const visibleSurface = manager.surfaces.get(SURFACE_ID);
135
+ expect(visibleSurface).toBeDefined();
136
+ expect(visibleSurface?.snapshot.visibleRect).not.toBeNull();
137
+ expect(testSetup.captureCharFrame()).toContain("1M");
138
+ });
139
+ });