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.
- package/package.json +1 -1
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +7 -1
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +26 -10
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { RGBA } from "../../ui";
|
|
2
|
-
import type {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
1263
|
-
const
|
|
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("
|
|
284
|
+
test("keeps indicator overlays out of the chart price range", () => {
|
|
284
285
|
const projection = projectChartData(chartFixture, 12, "line", false);
|
|
285
|
-
const
|
|
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
|
-
|
|
311
|
-
expect(
|
|
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();
|