gloomberb 0.7.3 → 0.7.4

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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/chart-renderer.ts +109 -32
  3. package/src/components/chart/chart-types.ts +18 -0
  4. package/src/components/chart/chart.test.ts +152 -5
  5. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  6. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  7. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  8. package/src/components/chart/market-session.ts +187 -0
  9. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  10. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  11. package/src/components/chart/stock-chart.tsx +36 -8
  12. package/src/components/chart/time-axis-label.tsx +56 -2
  13. package/src/components/command-bar/command-bar.test.tsx +1 -1
  14. package/src/components/command-bar/command-bar.tsx +7 -1
  15. package/src/components/command-bar/list-model.ts +8 -4
  16. package/src/components/command-bar/view-model.test.ts +20 -0
  17. package/src/components/command-bar/view-model.ts +15 -3
  18. package/src/components/ticker-list-table.tsx +4 -15
  19. package/src/core/state/session-persistence.ts +25 -0
  20. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  21. package/src/market-data/coordinator.test.ts +122 -2
  22. package/src/market-data/coordinator.ts +192 -55
  23. package/src/market-data/hooks.test.tsx +31 -0
  24. package/src/market-data/hooks.tsx +1 -0
  25. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  26. package/src/plugins/builtin/chat-controller.ts +8 -7
  27. package/src/plugins/builtin/chat.test.tsx +55 -0
  28. package/src/plugins/builtin/chat.tsx +20 -6
  29. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  30. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  31. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  32. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  33. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  34. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  35. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  36. package/src/plugins/builtin/sectors/index.tsx +30 -9
  37. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  38. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  39. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  40. package/src/sources/gloomberb-cloud.test.ts +55 -2
  41. package/src/sources/gloomberb-cloud.ts +180 -29
  42. package/src/sources/provider-router.test.ts +567 -10
  43. package/src/sources/provider-router.ts +323 -93
  44. package/src/sources/yahoo-finance.test.ts +37 -0
  45. package/src/sources/yahoo-finance.ts +48 -2
  46. package/src/state/app-bootstrap.test.ts +50 -23
  47. package/src/state/app-bootstrap.ts +26 -10
  48. package/src/state/session-persistence.test.ts +5 -3
  49. package/src/state/use-inline-tickers.ts +3 -0
  50. package/src/state/use-quote-streaming.ts +11 -0
  51. package/src/types/data-provider.ts +5 -1
  52. package/src/types/financials.ts +1 -0
  53. package/src/utils/api-client.test.ts +120 -0
  54. package/src/utils/api-client.ts +66 -19
  55. package/src/utils/exchanges.test.ts +16 -0
  56. package/src/utils/exchanges.ts +33 -0
  57. package/src/utils/options.ts +1 -1
  58. package/src/utils/price-history.test.ts +28 -1
  59. package/src/utils/price-history.ts +16 -0
  60. package/src/utils/quote-freshness.test.ts +150 -0
  61. package/src/utils/quote-freshness.ts +42 -1
  62. package/src/utils/quote-resolution.ts +2 -0
  63. package/src/utils/quote-time.test.ts +34 -0
  64. package/src/utils/quote-time.ts +18 -5
  65. package/src/version.ts +1 -1
  66. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -1,7 +1,17 @@
1
1
  import { RGBA } from "../../ui";
2
- import type { ChartAxisMode, ChartColors, ChartIndicatorOverlays, Pixel, PixelBuffer, ChartRenderMode } from "./chart-types";
2
+ import type {
3
+ ChartAxisMode,
4
+ ChartColors,
5
+ ChartIndicatorOverlays,
6
+ ChartMarketSession,
7
+ ChartSessionBackgroundSpan,
8
+ Pixel,
9
+ PixelBuffer,
10
+ ChartRenderMode,
11
+ } from "./chart-types";
3
12
  import type { ProjectedChartPoint } from "./chart-data";
4
13
  import { formatCompactMarketPriceWithCurrency, formatMarketPrice, resolveAssetDisplayKind } from "../../utils/market-format";
14
+ import { resolveExtendedHoursBackgroundSpans } from "./market-session";
5
15
 
6
16
  // ---------------------------------------------------------------------------
7
17
  // Styled output types (unchanged public API)
@@ -26,7 +36,7 @@ export interface StyledContent {
26
36
  const LAYER_GRID = 0;
27
37
  const LAYER_FILL = 1;
28
38
  const LAYER_DATA = 2;
29
- const LAYER_OVERLAY = 2;
39
+ const LAYER_OVERLAY = 1;
30
40
  const LAYER_CROSSHAIR = 3;
31
41
 
32
42
  function normalizeCount(value: number, min: number): number {
@@ -135,6 +145,8 @@ export function resolveChartPalette(
135
145
  axisColor: baseColors.textDim,
136
146
  activeRangeColor: baseColors.text,
137
147
  inactiveRangeColor: blendHex(baseColors.bg, baseColors.textDim, 0.75),
148
+ preMarketBgColor: blendHex(baseColors.bg, "#8a641f", 0.28),
149
+ postMarketBgColor: blendHex(baseColors.bg, "#7a4624", 0.28),
138
150
  candleUp: baseColors.positive,
139
151
  candleDown: baseColors.negative,
140
152
  wickUp: blendHex(baseColors.positive, baseColors.text, 0.35),
@@ -150,10 +162,12 @@ export function createPixelBuffer(width: number, heightPixels: number): PixelBuf
150
162
  const bufferWidth = normalizeCount(width, 0);
151
163
  const bufferHeight = normalizeCount(heightPixels, 0);
152
164
  const pixels: (Pixel | null)[][] = [];
165
+ const backgrounds: (string | null)[][] = [];
153
166
  for (let y = 0; y < bufferHeight; y++) {
154
167
  pixels.push(new Array(bufferWidth).fill(null));
168
+ backgrounds.push(new Array(bufferWidth).fill(null));
155
169
  }
156
- return { width: bufferWidth, height: bufferHeight, pixels };
170
+ return { width: bufferWidth, height: bufferHeight, pixels, backgrounds };
157
171
  }
158
172
 
159
173
  function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
@@ -224,6 +238,27 @@ function fillRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: numb
224
238
  }
225
239
  }
226
240
 
241
+ function setBackgroundPixel(buf: PixelBuffer, x: number, y: number, color: string) {
242
+ const px = Math.round(x);
243
+ const py = Math.round(y);
244
+ if (px < 0 || px >= buf.width || py < 0 || py >= buf.height) return;
245
+ const row = buf.backgrounds[py];
246
+ if (!row) return;
247
+ row[px] = color;
248
+ }
249
+
250
+ function fillBackgroundRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: number, color: string) {
251
+ const left = Math.max(Math.min(Math.round(x0), Math.round(x1)), 0);
252
+ const right = Math.min(Math.max(Math.round(x0), Math.round(x1)), Math.max(buf.width - 1, 0));
253
+ const top = Math.max(Math.min(Math.round(y0), Math.round(y1)), 0);
254
+ const bottom = Math.min(Math.max(Math.round(y0), Math.round(y1)), Math.max(buf.height - 1, 0));
255
+ for (let y = top; y <= bottom; y += 1) {
256
+ for (let x = left; x <= right; x += 1) {
257
+ setBackgroundPixel(buf, x, y, color);
258
+ }
259
+ }
260
+ }
261
+
227
262
  function drawWickOutsideBody(
228
263
  buf: PixelBuffer,
229
264
  x: number,
@@ -329,6 +364,38 @@ export function getPointTerminalColumn(index: number, pointCount: number, width:
329
364
  return Math.min(Math.max(Math.floor(getPointDotX(index, pointCount, width, mode) / 2), 0), width - 1);
330
365
  }
331
366
 
367
+ function getTimePointBand(index: number, pointCount: number, width: number): { left: number; right: number } {
368
+ const currentX = getSeriesPosition(index, pointCount, width);
369
+ const previousX = index > 0 ? getSeriesPosition(index - 1, pointCount, width) : currentX;
370
+ const nextX = index < pointCount - 1 ? getSeriesPosition(index + 1, pointCount, width) : currentX;
371
+ return {
372
+ left: index === 0 ? 0 : Math.floor((previousX + currentX) / 2) + 1,
373
+ right: index === pointCount - 1 ? Math.max(width - 1, 0) : Math.floor((currentX + nextX) / 2),
374
+ };
375
+ }
376
+
377
+ function getSessionBackgroundColor(span: ChartSessionBackgroundSpan, colors: ChartColors): string {
378
+ return span.kind === "pre" ? colors.preMarketBgColor : colors.postMarketBgColor;
379
+ }
380
+
381
+ function drawSessionBackgrounds(
382
+ buf: PixelBuffer,
383
+ points: ProjectedChartPoint[],
384
+ spans: readonly ChartSessionBackgroundSpan[],
385
+ yTop: number,
386
+ yBottom: number,
387
+ colors: ChartColors,
388
+ ) {
389
+ if (spans.length === 0 || points.length === 0) return;
390
+ for (const span of spans) {
391
+ const startIndex = Math.max(Math.min(span.startIndex, points.length - 1), 0);
392
+ const endIndex = Math.max(Math.min(span.endIndex, points.length - 1), startIndex);
393
+ const startBand = getTimePointBand(startIndex, points.length, buf.width);
394
+ const endBand = getTimePointBand(endIndex, points.length, buf.width);
395
+ fillBackgroundRect(buf, startBand.left, yTop, endBand.right, yBottom, getSessionBackgroundColor(span, colors));
396
+ }
397
+ }
398
+
332
399
  // ---------------------------------------------------------------------------
333
400
  // Chart mode renderers
334
401
  // ---------------------------------------------------------------------------
@@ -573,14 +640,19 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
573
640
  let topLayer = -1;
574
641
 
575
642
  // Track dots per layer so we can render only the top layer's dots
576
- const dotsByLayer: Map<number, number> = new Map(); // layer → braille bits
643
+ const dotsByLayer: Map<number, number> = new Map(); // layer -> braille bits
577
644
  const colorByLayer: Map<number, Map<string, number>> = new Map();
645
+ const backgroundCounts: Map<string, number> = new Map();
578
646
 
579
647
  for (let dy = 0; dy < 4; dy++) {
580
648
  for (let dx = 0; dx < 2; dx++) {
581
649
  const px = col * 2 + dx;
582
650
  const py = row * 4 + dy;
583
651
  if (px >= buf.width || py >= buf.height) continue;
652
+ const bg = buf.backgrounds[py]?.[px] ?? null;
653
+ if (bg) {
654
+ backgroundCounts.set(bg, (backgroundCounts.get(bg) || 0) + 1);
655
+ }
584
656
  const pixel = buf.pixels[py]?.[px] ?? null;
585
657
  if (pixel) {
586
658
  const bit = BRAILLE_DOT[dy]![dx]!;
@@ -598,14 +670,19 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
598
670
  let char: string;
599
671
  let cellFg: string | undefined;
600
672
  let cellBg: string | undefined;
673
+ let bestBackgroundCount = 0;
674
+ for (const [bg, count] of backgroundCounts) {
675
+ if (count > bestBackgroundCount) {
676
+ bestBackgroundCount = count;
677
+ cellBg = bg;
678
+ }
679
+ }
601
680
 
602
681
  if (topLayer < 0) {
603
682
  char = " ";
604
683
  cellFg = undefined;
605
- cellBg = undefined;
606
684
  } else {
607
- // Only show dots from the highest layer — keeps lines thin in mixed
608
- // cells (e.g. area chart where line and fill overlap).
685
+ // Only show dots from the highest layer so lines stay thin in mixed cells.
609
686
  const pattern = dotsByLayer.get(topLayer) || 0;
610
687
  char = String.fromCharCode(BRAILLE_BASE + pattern);
611
688
 
@@ -617,7 +694,6 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
617
694
  }
618
695
 
619
696
  cellFg = topColor;
620
- cellBg = undefined;
621
697
  }
622
698
 
623
699
  if (char === runChar && cellFg === runFg && cellBg === runBg) {
@@ -823,6 +899,10 @@ function formatClockTime(date: Date, unit: AxisLabelUnit): string {
823
899
  }
824
900
  }
825
901
 
902
+ function formatFullCalendarDate(date: Date): string {
903
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
904
+ }
905
+
826
906
  function isSameCalendarDay(a: Date, b: Date): boolean {
827
907
  return a.getFullYear() === b.getFullYear()
828
908
  && a.getMonth() === b.getMonth()
@@ -985,6 +1065,10 @@ function formatCursorTimeAxisValue(
985
1065
  const minGapMs = getMinPositiveGapMs(validDates);
986
1066
  const effectiveStepMs = Math.max(spanMs / Math.max(roughLabelCount - 1, 1), minGapMs || 0);
987
1067
  const unit = resolveAxisLabelUnit(effectiveStepMs);
1068
+ if (unit === "year" || unit === "month") {
1069
+ return formatFullCalendarDate(date);
1070
+ }
1071
+
988
1072
  const counterpart = isSameCalendarDay(first, last)
989
1073
  ? first
990
1074
  : isSameCalendarDay(date, first)
@@ -1152,6 +1236,7 @@ export interface RenderChartOptions {
1152
1236
  colors: ResolvedChartPalette;
1153
1237
  timeAxisDates?: Array<Date | string | number>;
1154
1238
  indicators?: ChartIndicatorOverlays | null;
1239
+ marketSession?: ChartMarketSession | null;
1155
1240
  }
1156
1241
 
1157
1242
  export interface ChartScene {
@@ -1164,6 +1249,7 @@ export interface ChartScene {
1164
1249
  mode: ChartRenderMode;
1165
1250
  colors: ResolvedChartPalette;
1166
1251
  indicators: ChartIndicatorOverlays | null;
1252
+ sessionBackgroundSpans: ChartSessionBackgroundSpan[];
1167
1253
  min: number;
1168
1254
  max: number;
1169
1255
  activeIdx: number;
@@ -1225,27 +1311,6 @@ export function getActivePointIndex(
1225
1311
  return bestIndex;
1226
1312
  }
1227
1313
 
1228
- function getIndicatorOverlayValues(indicators: ChartIndicatorOverlays | null | undefined): number[] {
1229
- if (!indicators) return [];
1230
-
1231
- const values: number[] = [];
1232
- for (const line of indicators.smaLines) {
1233
- values.push(...line.points.map((point) => point.value));
1234
- }
1235
- for (const line of indicators.emaLines) {
1236
- values.push(...line.points.map((point) => point.value));
1237
- }
1238
- if (indicators.bollinger) {
1239
- values.push(
1240
- ...indicators.bollinger.upper.map((point) => point.value),
1241
- ...indicators.bollinger.middle.map((point) => point.value),
1242
- ...indicators.bollinger.lower.map((point) => point.value),
1243
- );
1244
- }
1245
-
1246
- return values.filter((value) => Number.isFinite(value));
1247
- }
1248
-
1249
1314
  export function buildChartScene(
1250
1315
  points: ProjectedChartPoint[],
1251
1316
  opts: RenderChartOptions,
@@ -1259,9 +1324,8 @@ export function buildChartScene(
1259
1324
  const dataMax = isHighLowMode(opts.mode)
1260
1325
  ? Math.max(...points.map((point) => point.high))
1261
1326
  : Math.max(...points.map((point) => point.close));
1262
- const indicatorValues = getIndicatorOverlayValues(opts.indicators);
1263
- const min = indicatorValues.length > 0 ? Math.min(dataMin, ...indicatorValues) : dataMin;
1264
- const max = indicatorValues.length > 0 ? Math.max(dataMax, ...indicatorValues) : dataMax;
1327
+ const min = dataMin;
1328
+ const max = dataMax;
1265
1329
  const activeIdx = getActivePointIndex(points.length, dimensions.width, opts.cursorX, opts.mode);
1266
1330
  const activePoint = points[activeIdx]!;
1267
1331
  const range = max - min || 1;
@@ -1290,6 +1354,10 @@ export function buildChartScene(
1290
1354
  : max - (cursorY / Math.max(dimensions.chartRows - 1, 1)) * range;
1291
1355
 
1292
1356
  const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
1357
+ const sessionBackgroundSpans = resolveExtendedHoursBackgroundSpans(
1358
+ points.map((point) => point.date),
1359
+ opts.marketSession,
1360
+ );
1293
1361
 
1294
1362
  return {
1295
1363
  points,
@@ -1301,6 +1369,7 @@ export function buildChartScene(
1301
1369
  mode: opts.mode,
1302
1370
  colors: opts.colors,
1303
1371
  indicators: opts.indicators ?? null,
1372
+ sessionBackgroundSpans,
1304
1373
  min,
1305
1374
  max,
1306
1375
  activeIdx,
@@ -1412,6 +1481,14 @@ export function renderChart(
1412
1481
  getAxisFractionDigitFloor(assetCategory, priceRange),
1413
1482
  )
1414
1483
  : null;
1484
+ drawSessionBackgrounds(
1485
+ buf,
1486
+ points,
1487
+ scene.sessionBackgroundSpans,
1488
+ 0,
1489
+ showVolume ? volDotBottom : chartDotBottom,
1490
+ palette,
1491
+ );
1415
1492
  drawGridLines(buf, gridLines.map((line) => line.y), palette.gridColor);
1416
1493
 
1417
1494
  switch (mode) {
@@ -7,6 +7,7 @@ export type ChartAxisMode = "price" | "percent";
7
7
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
8
8
  export type ResolvedChartRenderer = "kitty" | "braille";
9
9
  export type ComparisonChartRenderMode = "area" | "line";
10
+ export type ChartSessionBackgroundKind = "pre" | "post";
10
11
 
11
12
  export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
12
13
  export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc", "hlc"];
@@ -46,6 +47,7 @@ export interface PixelBuffer {
46
47
  width: number;
47
48
  height: number; // virtual pixel rows (4x terminal rows for braille rendering)
48
49
  pixels: (Pixel | null)[][]; // [y][x] = pixel with color and layer, or null
50
+ backgrounds: (string | null)[][];
49
51
  }
50
52
 
51
53
  export interface ChartColors {
@@ -59,6 +61,22 @@ export interface ChartColors {
59
61
  axisColor: string;
60
62
  activeRangeColor: string;
61
63
  inactiveRangeColor: string;
64
+ preMarketBgColor: string;
65
+ postMarketBgColor: string;
66
+ }
67
+
68
+ export interface ChartMarketSession {
69
+ timeZone: string;
70
+ regularStartMinutes: number;
71
+ regularEndMinutes: number;
72
+ preMarketStartMinutes: number;
73
+ postMarketEndMinutes: number;
74
+ }
75
+
76
+ export interface ChartSessionBackgroundSpan {
77
+ kind: ChartSessionBackgroundKind;
78
+ startIndex: number;
79
+ endIndex: number;
62
80
  }
63
81
 
64
82
  export interface VisibleWindow {
@@ -7,8 +7,9 @@ import {
7
7
  } from "./chart-data";
8
8
  import { stepCursorTowards } from "./cursor-motion";
9
9
  import { buildChartScene, buildCursorTimeAxisSegments, buildTimeAxis, formatAxisValue, formatCursorAxisValue, renderChart, resolveChartAxisWidth, resolveChartPalette } from "./chart-renderer";
10
- import { buildCursorTimeAxisOverlay } from "./time-axis-label";
10
+ import { buildCursorTimeAxisOverlay, resolveCursorDateFromAxis } from "./time-axis-label";
11
11
  import { buildCursorPriceAxisOverlay } from "./price-axis-labels";
12
+ import { resolveChartMarketSession, resolveExtendedHoursBackgroundSpans } from "./market-session";
12
13
  import type { PricePoint } from "../../types/financials";
13
14
  import type { ChartRenderMode } from "./chart-types";
14
15
 
@@ -280,9 +281,19 @@ describe("renderChart", () => {
280
281
  expect(result.axisLabels.every((entry) => entry.label.includes("."))).toBe(true);
281
282
  });
282
283
 
283
- test("includes visible indicator overlays in the chart price range", () => {
284
+ test("keeps indicator overlays out of the chart price range", () => {
284
285
  const projection = projectChartData(chartFixture, 12, "line", false);
285
- const scene = buildChartScene(projection.points, {
286
+ const baseScene = buildChartScene(projection.points, {
287
+ width: 12,
288
+ height: 6,
289
+ showVolume: false,
290
+ volumeHeight: 0,
291
+ cursorX: null,
292
+ cursorY: null,
293
+ mode: projection.effectiveMode,
294
+ colors: palette,
295
+ });
296
+ const sceneWithIndicators = buildChartScene(projection.points, {
286
297
  width: 12,
287
298
  height: 6,
288
299
  showVolume: false,
@@ -300,6 +311,63 @@ describe("renderChart", () => {
300
311
  { index: 1, value: 18 },
301
312
  ],
302
313
  }],
314
+ emaLines: [{
315
+ period: 2,
316
+ color: "#00ffff",
317
+ points: [
318
+ { index: 0, value: 4 },
319
+ { index: 1, value: 20 },
320
+ ],
321
+ }],
322
+ bollinger: {
323
+ color: "#ffaa00",
324
+ upper: [
325
+ { index: 0, value: 22 },
326
+ { index: 1, value: 23 },
327
+ ],
328
+ middle: [
329
+ { index: 0, value: 14 },
330
+ { index: 1, value: 15 },
331
+ ],
332
+ lower: [
333
+ { index: 0, value: 3 },
334
+ { index: 1, value: 4 },
335
+ ],
336
+ },
337
+ rsi: null,
338
+ macd: null,
339
+ },
340
+ });
341
+
342
+ expect(sceneWithIndicators?.min).toBe(baseScene?.min);
343
+ expect(sceneWithIndicators?.max).toBe(baseScene?.max);
344
+ });
345
+
346
+ test("draws indicator overlays below price pixels", () => {
347
+ const points: PricePoint[] = [
348
+ { date: new Date("2024-01-02"), close: 10, volume: 100 },
349
+ { date: new Date("2024-01-03"), close: 11, volume: 120 },
350
+ { date: new Date("2024-01-04"), close: 12, volume: 140 },
351
+ ];
352
+ const result = renderChart(points, {
353
+ width: 12,
354
+ height: 6,
355
+ showVolume: false,
356
+ volumeHeight: 0,
357
+ cursorX: null,
358
+ cursorY: null,
359
+ mode: "line",
360
+ colors: palette,
361
+ indicators: {
362
+ smaLines: [{
363
+ period: 2,
364
+ color: "#ff00ff",
365
+ points: [
366
+ { index: 0, value: 10 },
367
+ { index: 1, value: 11 },
368
+ { index: 2, value: 12 },
369
+ ],
370
+ }],
303
371
  emaLines: [],
304
372
  bollinger: null,
305
373
  rsi: null,
@@ -307,8 +375,9 @@ describe("renderChart", () => {
307
375
  },
308
376
  });
309
377
 
310
- expect(scene?.min).toBe(6);
311
- expect(scene?.max).toBe(18);
378
+ const pixels = result.pixelBuffer?.pixels.flat().filter(Boolean) ?? [];
379
+ expect(pixels.some((pixel) => pixel?.color === palette.lineColor)).toBe(true);
380
+ expect(pixels.some((pixel) => pixel?.color === "#ff00ff")).toBe(false);
312
381
  });
313
382
 
314
383
  test("normalizes fractional web dimensions before drawing volume bars", () => {
@@ -434,6 +503,31 @@ describe("renderChart", () => {
434
503
  expect(segments.find((segment) => segment.highlighted)?.text).toBe("12:30");
435
504
  });
436
505
 
506
+ test("keeps day precision for cursor labels when the axis coarsens to months", () => {
507
+ const dates = Array.from({ length: 12 }, (_, index) => new Date(2025, index, 1));
508
+ const width = 48;
509
+ const segments = buildCursorTimeAxisSegments({
510
+ timeLabels: buildTimeAxis(dates, width),
511
+ width,
512
+ cursorColumn: 27,
513
+ cursorDate: new Date(2025, 8, 15),
514
+ dates,
515
+ });
516
+
517
+ expect(segments.find((segment) => segment.highlighted)?.text).toBe("Sep 15 2025");
518
+ });
519
+
520
+ test("resolves cursor dates from the full source axis between coarse month ticks", () => {
521
+ const dates = Array.from({ length: 32 }, (_, index) => new Date(2025, 2, index + 1));
522
+ const resolved = resolveCursorDateFromAxis({
523
+ dates,
524
+ width: 32,
525
+ cursorColumn: 15,
526
+ });
527
+
528
+ expect(resolved?.toISOString()).toBe(dates[15]!.toISOString());
529
+ });
530
+
437
531
  test("positions desktop cursor x-axis overlay from exact pixels", () => {
438
532
  const dates = Array.from({ length: 13 }, (_, index) => new Date(2026, 0, 5, 9, 30 + index * 30));
439
533
  const width = 72;
@@ -499,6 +593,59 @@ describe("renderChart", () => {
499
593
  expect(buildTimeAxis(yearlyDates, 48)).toBe("2020 2021 2022 2023 2024 2025");
500
594
  });
501
595
 
596
+ test("marks US premarket and postmarket bars for chart backgrounds", () => {
597
+ const session = resolveChartMarketSession([{ exchange: "NASDAQ", currency: "USD", assetCategory: "STK" }]);
598
+ const dates = [
599
+ new Date("2026-05-14T11:00:00Z"),
600
+ new Date("2026-05-14T14:00:00Z"),
601
+ new Date("2026-05-14T20:30:00Z"),
602
+ ];
603
+
604
+ expect(resolveExtendedHoursBackgroundSpans(dates, session)).toEqual([
605
+ { kind: "pre", startIndex: 0, endIndex: 0 },
606
+ { kind: "post", startIndex: 2, endIndex: 2 },
607
+ ]);
608
+ });
609
+
610
+ test("omits extended-hours backgrounds for wide intraday windows", () => {
611
+ const session = resolveChartMarketSession([{ exchange: "NASDAQ", currency: "USD", assetCategory: "STK" }]);
612
+ const dates = Array.from({ length: 12 }).flatMap((_, dayIndex) => {
613
+ const day = 4 + dayIndex;
614
+ return [
615
+ new Date(Date.UTC(2026, 4, day, 11, 0)),
616
+ new Date(Date.UTC(2026, 4, day, 14, 0)),
617
+ new Date(Date.UTC(2026, 4, day, 20, 30)),
618
+ ];
619
+ });
620
+
621
+ expect(resolveExtendedHoursBackgroundSpans(dates, session)).toEqual([]);
622
+ });
623
+
624
+ test("paints extended-hours chart cells as background colors", () => {
625
+ const session = resolveChartMarketSession([{ exchange: "NASDAQ", currency: "USD", assetCategory: "STK" }]);
626
+ const points: PricePoint[] = [
627
+ { date: new Date("2026-05-14T11:00:00Z"), close: 10, volume: 100 },
628
+ { date: new Date("2026-05-14T14:00:00Z"), close: 12, volume: 120 },
629
+ { date: new Date("2026-05-14T20:30:00Z"), close: 11, volume: 110 },
630
+ ];
631
+
632
+ const result = renderChart(points, {
633
+ width: 12,
634
+ height: 4,
635
+ showVolume: false,
636
+ volumeHeight: 0,
637
+ cursorX: null,
638
+ cursorY: null,
639
+ mode: "line",
640
+ colors: palette,
641
+ marketSession: session,
642
+ });
643
+
644
+ const backgrounds = result.lines.flatMap((line) => line.chunks.map((chunk) => chunk.bg));
645
+ expect(backgrounds).toContain(palette.preMarketBgColor);
646
+ expect(backgrounds).toContain(palette.postMarketBgColor);
647
+ });
648
+
502
649
  test("uses source time-axis dates instead of projected extrema dates when provided", () => {
503
650
  const sourceHistory = Array.from({ length: 96 }, (_, index) => ({
504
651
  date: new Date(Date.UTC(2026, 2, 25, index * 3)),
@@ -8,6 +8,14 @@ import {
8
8
  } from "./comparison-chart-renderer";
9
9
  import type { ComparisonChartSeries } from "./chart-types";
10
10
 
11
+ const comparisonColors = {
12
+ bgColor: "#000000",
13
+ gridColor: "#333333",
14
+ crosshairColor: "#ffffff",
15
+ preMarketBgColor: "#001b44",
16
+ postMarketBgColor: "#442300",
17
+ };
18
+
11
19
  function makeSeries(symbol: string, color: string, closes: number[]): ComparisonChartSeries {
12
20
  return {
13
21
  symbol,
@@ -51,11 +59,7 @@ describe("renderComparisonChart", () => {
51
59
  cursorX: null,
52
60
  cursorY: null,
53
61
  selectedSymbol: "AAPL",
54
- colors: {
55
- bgColor: "#000000",
56
- gridColor: "#333333",
57
- crosshairColor: "#ffffff",
58
- },
62
+ colors: comparisonColors,
59
63
  });
60
64
 
61
65
  expect(result.axisFractionDigits).toBeGreaterThanOrEqual(1);
@@ -78,11 +82,7 @@ describe("renderComparisonChart", () => {
78
82
  cursorX: null,
79
83
  cursorY: null,
80
84
  selectedSymbol: "AAPL",
81
- colors: {
82
- bgColor: "#000000",
83
- gridColor: "#333333",
84
- crosshairColor: "#ffffff",
85
- },
85
+ colors: comparisonColors,
86
86
  });
87
87
 
88
88
  expect(result.axisFractionDigits).toBe(1);
@@ -105,11 +105,7 @@ describe("renderComparisonChart", () => {
105
105
  cursorX: 5,
106
106
  cursorY: null,
107
107
  selectedSymbol: "MSFT",
108
- colors: {
109
- bgColor: "#000000",
110
- gridColor: "#333333",
111
- crosshairColor: "#ffffff",
112
- },
108
+ colors: comparisonColors,
113
109
  });
114
110
 
115
111
  expect(result.lines).toHaveLength(6);
@@ -134,11 +130,7 @@ describe("renderComparisonChart", () => {
134
130
  cursorX: null,
135
131
  cursorY: null,
136
132
  selectedSymbol: "AAPL",
137
- colors: {
138
- bgColor: "#000000",
139
- gridColor: "#333333",
140
- crosshairColor: "#ffffff",
141
- },
133
+ colors: comparisonColors,
142
134
  });
143
135
 
144
136
  expect(result.lines.length).toBeGreaterThan(0);
@@ -160,11 +152,7 @@ describe("renderComparisonChart", () => {
160
152
  cursorX: null,
161
153
  cursorY: null,
162
154
  selectedSymbol: "AAPL",
163
- colors: {
164
- bgColor: "#000000",
165
- gridColor: "#333333",
166
- crosshairColor: "#ffffff",
167
- },
155
+ colors: comparisonColors,
168
156
  });
169
157
 
170
158
  const chunks = result.lines.flatMap((line) => line.chunks);
@@ -188,11 +176,7 @@ describe("renderComparisonChart", () => {
188
176
  cursorX: 9,
189
177
  cursorY: null,
190
178
  selectedSymbol: "NVDA",
191
- colors: {
192
- bgColor: "#000000",
193
- gridColor: "#333333",
194
- crosshairColor: "#ffffff",
195
- },
179
+ colors: comparisonColors,
196
180
  });
197
181
 
198
182
  expect(scene).not.toBeNull();