hyperprop-charting-library 0.1.118 → 0.1.120

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.
@@ -43,7 +43,10 @@ var DEFAULT_GRID_OPTIONS = {
43
43
  verticalLines: true,
44
44
  xTickCount: 8,
45
45
  yTickCount: 6,
46
- horizontalTickCount: 6
46
+ horizontalTickCount: 6,
47
+ sessionSeparators: false,
48
+ sessionSeparatorColor: "#3b4150",
49
+ sessionSeparatorOpacity: 0.55
47
50
  };
48
51
  var DEFAULT_AXIS_OPTIONS = {
49
52
  lineColor: "#3b3f47",
@@ -432,6 +435,60 @@ var computeStdDevSeries = (data, length, source) => {
432
435
  }
433
436
  return result;
434
437
  };
438
+ var VWAP_SESSION_ANCHOR_MS = 22 * 36e5;
439
+ var vwapSessionDayOf = (ms) => Math.floor((ms - VWAP_SESSION_ANCHOR_MS) / 864e5);
440
+ var computeVwapSeries = (data, band) => {
441
+ const result = new Array(data.length).fill(null);
442
+ let session = Number.NaN;
443
+ let cumPv = 0;
444
+ let cumPv2 = 0;
445
+ let cumV = 0;
446
+ for (let i = 0; i < data.length; i += 1) {
447
+ const point = data[i];
448
+ if (!point) continue;
449
+ const day = vwapSessionDayOf(point.time.getTime());
450
+ if (day !== session) {
451
+ session = day;
452
+ cumPv = 0;
453
+ cumPv2 = 0;
454
+ cumV = 0;
455
+ }
456
+ const typical = (point.h + point.l + point.c) / 3;
457
+ const volume = Math.max(0, point.v ?? 0);
458
+ cumPv += typical * volume;
459
+ cumPv2 += typical * typical * volume;
460
+ cumV += volume;
461
+ if (cumV <= 0) continue;
462
+ const vwap = cumPv / cumV;
463
+ if (band === "vwap") {
464
+ result[i] = vwap;
465
+ } else {
466
+ const variance = Math.max(0, cumPv2 / cumV - vwap * vwap);
467
+ result[i] = Math.sqrt(variance);
468
+ }
469
+ }
470
+ for (let i = 1; i < data.length; i += 1) {
471
+ const point = data[i];
472
+ const prev = data[i - 1];
473
+ const next = data[i + 1];
474
+ if (!point || !prev || !next) continue;
475
+ const day = vwapSessionDayOf(point.time.getTime());
476
+ if (day !== vwapSessionDayOf(prev.time.getTime()) && day === vwapSessionDayOf(next.time.getTime())) {
477
+ result[i] = null;
478
+ }
479
+ }
480
+ return result;
481
+ };
482
+ var computeBollingerSeries = (data, length, multiplier, source, band) => {
483
+ const basis = computeSmaSeries(data, length, source);
484
+ if (band === "basis") return basis;
485
+ const dev = computeStdDevSeries(data, length, source);
486
+ return basis.map((value, idx) => {
487
+ const sigma = dev[idx];
488
+ if (value == null || sigma == null) return null;
489
+ return band === "upper" ? value + multiplier * sigma : value - multiplier * sigma;
490
+ });
491
+ };
435
492
  var computeRsiSeries = (data, length) => {
436
493
  const result = new Array(data.length).fill(null);
437
494
  if (data.length < 2) return result;
@@ -558,6 +615,39 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
558
615
  }
559
616
  ctx.restore();
560
617
  };
618
+ var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
619
+ if (!renderContext.yFromPrice || opacity <= 0) return;
620
+ const yFromPrice = renderContext.yFromPrice;
621
+ ctx.save();
622
+ ctx.globalAlpha = Math.min(1, Math.max(0, opacity));
623
+ ctx.fillStyle = color;
624
+ let runStart = -1;
625
+ const flushRun = (endIndexExclusive) => {
626
+ if (runStart < 0) return;
627
+ ctx.beginPath();
628
+ for (let i = runStart; i < endIndexExclusive; i += 1) {
629
+ const x = renderContext.xFromIndex(i);
630
+ const y = yFromPrice(upper[i]);
631
+ if (i === runStart) ctx.moveTo(x, y);
632
+ else ctx.lineTo(x, y);
633
+ }
634
+ for (let i = endIndexExclusive - 1; i >= runStart; i -= 1) {
635
+ ctx.lineTo(renderContext.xFromIndex(i), yFromPrice(lower[i]));
636
+ }
637
+ ctx.closePath();
638
+ ctx.fill();
639
+ runStart = -1;
640
+ };
641
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
642
+ const up = upper[index];
643
+ const lo = lower[index];
644
+ const valid = Number.isFinite(up ?? Number.NaN) && Number.isFinite(lo ?? Number.NaN);
645
+ if (valid && runStart < 0) runStart = index;
646
+ if (!valid) flushRun(index);
647
+ }
648
+ flushRun(renderContext.endIndex + 1);
649
+ ctx.restore();
650
+ };
561
651
  var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines, options = {}) => {
562
652
  const visible = [];
563
653
  for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
@@ -819,6 +909,89 @@ var BUILTIN_HMA_INDICATOR = {
819
909
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
820
910
  }
821
911
  };
912
+ var BUILTIN_VWAP_INDICATOR = {
913
+ id: "vwap",
914
+ name: "VWAP",
915
+ pane: "overlay",
916
+ defaultInputs: {
917
+ color: "#f59e0b",
918
+ width: 2,
919
+ showBands: false,
920
+ bandMultiplier: 1,
921
+ bandColor: "#94a3b8",
922
+ bandFillOpacity: 0.06
923
+ },
924
+ draw: (ctx, renderContext, inputs) => {
925
+ const vwap = withCachedSeries(
926
+ "vwap|line",
927
+ renderContext.data,
928
+ () => computeVwapSeries(renderContext.data, "vwap")
929
+ );
930
+ if (inputs.showBands) {
931
+ const sigma = withCachedSeries(
932
+ "vwap|sigma",
933
+ renderContext.data,
934
+ () => computeVwapSeries(renderContext.data, "sigma")
935
+ );
936
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
937
+ const upper = vwap.map((value, idx) => {
938
+ const s = sigma[idx];
939
+ return value == null || s == null ? null : value + multiplier * s;
940
+ });
941
+ const lower = vwap.map((value, idx) => {
942
+ const s = sigma[idx];
943
+ return value == null || s == null ? null : value - multiplier * s;
944
+ });
945
+ const bandColor = inputs.bandColor ?? "#94a3b8";
946
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.bandFillOpacity) || 0.06);
947
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, 1);
948
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
949
+ }
950
+ drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
951
+ }
952
+ };
953
+ var BUILTIN_BOLLINGER_INDICATOR = {
954
+ id: "bollinger",
955
+ name: "BB",
956
+ pane: "overlay",
957
+ // Basis is pink so it stays distinguishable from the (orange) VWAP line
958
+ // when both overlays are active.
959
+ defaultInputs: {
960
+ length: 20,
961
+ multiplier: 2,
962
+ source: "close",
963
+ basisColor: "#ec4899",
964
+ bandColor: "#3b82f6",
965
+ width: 1.5,
966
+ fillOpacity: 0.05
967
+ },
968
+ draw: (ctx, renderContext, inputs) => {
969
+ const length = clampIndicatorLength(inputs.length, 20);
970
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
971
+ const source = inputs.source ?? "close";
972
+ const basis = withCachedSeries(
973
+ `bb|basis|${length}|${source}`,
974
+ renderContext.data,
975
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "basis")
976
+ );
977
+ const upper = withCachedSeries(
978
+ `bb|upper|${length}|${multiplier}|${source}`,
979
+ renderContext.data,
980
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "upper")
981
+ );
982
+ const lower = withCachedSeries(
983
+ `bb|lower|${length}|${multiplier}|${source}`,
984
+ renderContext.data,
985
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "lower")
986
+ );
987
+ const bandColor = inputs.bandColor ?? "#3b82f6";
988
+ const width = Number(inputs.width) || 1.5;
989
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
990
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
991
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
992
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
993
+ }
994
+ };
822
995
  var BUILTIN_STDDEV_INDICATOR = {
823
996
  id: "stddev",
824
997
  name: "StdDev",
@@ -902,6 +1075,8 @@ var BUILTIN_INDICATORS = [
902
1075
  BUILTIN_VWMA_INDICATOR,
903
1076
  BUILTIN_RMA_INDICATOR,
904
1077
  BUILTIN_HMA_INDICATOR,
1078
+ BUILTIN_VWAP_INDICATOR,
1079
+ BUILTIN_BOLLINGER_INDICATOR,
905
1080
  BUILTIN_STDDEV_INDICATOR,
906
1081
  BUILTIN_ATR_INDICATOR
907
1082
  ];
@@ -2754,15 +2929,31 @@ function createChart(element, options = {}) {
2754
2929
  const pct = diff / base * 100;
2755
2930
  const ticks = tick > 0 ? Math.round(diff / tick) : 0;
2756
2931
  const signed = (value, text) => `${value < 0 ? "\u2212" : value > 0 ? "+" : ""}${text}`;
2757
- const labelText = tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`;
2932
+ const labelLines = [
2933
+ tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`
2934
+ ];
2935
+ const barSpan = Math.abs(Math.round(p1.index) - Math.round(p0.index));
2936
+ if (barSpan > 0) {
2937
+ const t0 = getTimeForIndex(Math.round(p0.index));
2938
+ const t1 = getTimeForIndex(Math.round(p1.index));
2939
+ const spanMs = t0 && t1 ? Math.abs(t1.getTime() - t0.getTime()) : 0;
2940
+ labelLines.push(spanMs > 0 ? `${barSpan} bars, ${formatDuration(spanMs)}` : `${barSpan} bars`);
2941
+ }
2942
+ const pointValue = Number(drawing.pointValue);
2943
+ if (Number.isFinite(pointValue) && pointValue > 0) {
2944
+ const dollars = diff * pointValue;
2945
+ const money = Math.abs(dollars).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
2946
+ labelLines.push(`${signed(dollars, `$${money}`)} / contract`);
2947
+ }
2758
2948
  const prevFont = ctx.font;
2759
2949
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2760
2950
  const padding = 6;
2761
- const textW = ctx.measureText(labelText).width;
2951
+ const lineHeight = 15;
2952
+ const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
2762
2953
  const pillW = textW + padding * 2;
2763
- const pillH = 18;
2954
+ const pillH = labelLines.length * lineHeight + padding;
2764
2955
  const pillX = midX - pillW / 2;
2765
- const pillY = botY + 6;
2956
+ const pillY = botY + 6 + pillH <= fullChartBottom - 2 ? botY + 6 : Math.max(chartTop + 2, topY - 6 - pillH);
2766
2957
  ctx.fillStyle = mergedOptions.backgroundColor;
2767
2958
  fillRoundedRect(pillX, pillY, pillW, pillH, 4);
2768
2959
  ctx.save();
@@ -2773,7 +2964,9 @@ function createChart(element, options = {}) {
2773
2964
  ctx.fillStyle = drawing.color;
2774
2965
  ctx.textAlign = "center";
2775
2966
  ctx.textBaseline = "middle";
2776
- ctx.fillText(labelText, midX, pillY + pillH / 2);
2967
+ labelLines.forEach((line, lineIndex) => {
2968
+ ctx.fillText(line, midX, pillY + padding / 2 + lineIndex * lineHeight + lineHeight / 2);
2969
+ });
2777
2970
  ctx.font = prevFont;
2778
2971
  if (drawing.label) {
2779
2972
  drawDrawingLabel(drawing.label, midX, topY - 4, drawing.color);
@@ -2917,6 +3110,32 @@ function createChart(element, options = {}) {
2917
3110
  }
2918
3111
  ctx.restore();
2919
3112
  }
3113
+ if (grid.sessionSeparators && data.length > 1) {
3114
+ const first = Math.max(1, startIndex);
3115
+ const barDeltaMs = data.length > 1 ? Math.abs(data[Math.min(first, data.length - 1)].time.getTime() - data[Math.min(first, data.length - 1) - 1].time.getTime()) : 0;
3116
+ if (barDeltaMs > 0 && barDeltaMs < 0.9 * 864e5) {
3117
+ const SESSION_ANCHOR_MS = 22 * 36e5;
3118
+ const sessionDayOf = (ms) => Math.floor((ms - SESSION_ANCHOR_MS) / 864e5);
3119
+ ctx.save();
3120
+ ctx.globalAlpha = clamp(grid.sessionSeparatorOpacity, 0, 1);
3121
+ ctx.strokeStyle = grid.sessionSeparatorColor;
3122
+ ctx.lineWidth = 1;
3123
+ for (let index = first; index <= endIndex; index += 1) {
3124
+ const point = data[index];
3125
+ const prev = data[index - 1];
3126
+ if (!point || !prev) continue;
3127
+ if (sessionDayOf(point.time.getTime()) === sessionDayOf(prev.time.getTime())) {
3128
+ continue;
3129
+ }
3130
+ const x = chartLeft + (index - xStart) / xSpan * chartWidth;
3131
+ ctx.beginPath();
3132
+ ctx.moveTo(crisp(x), crisp(chartTop));
3133
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
3134
+ ctx.stroke();
3135
+ }
3136
+ ctx.restore();
3137
+ }
3138
+ }
2920
3139
  const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
2921
3140
  const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
2922
3141
  const lastDataIndex = data.length - 1;
@@ -3523,8 +3742,9 @@ function createChart(element, options = {}) {
3523
3742
  }
3524
3743
  return typeof value === "string" && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value.trim());
3525
3744
  };
3745
+ const LEGEND_EXCLUDED_INPUT_KEYS = /* @__PURE__ */ new Set(["width", "bandMultiplier", "bandFillOpacity", "fillOpacity"]);
3526
3746
  const labelEntries = activeOverlayIndicators.map(({ indicator, plugin }) => {
3527
- const inputValues = Object.entries(indicator.inputs).filter(([, value]) => isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3747
+ const inputValues = Object.entries(indicator.inputs).filter(([key, value]) => !LEGEND_EXCLUDED_INPUT_KEYS.has(key) && typeof value !== "boolean" && isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3528
3748
  if (labels.showIndicatorNames && labels.showIndicatorValues && inputValues.length > 0) {
3529
3749
  return `${plugin.name} ${inputValues.join(" ")}`;
3530
3750
  }
@@ -4502,7 +4722,9 @@ function createChart(element, options = {}) {
4502
4722
  points: [point, normalizeDrawingPoint(point.index + width2, point.price - priceOffset)],
4503
4723
  color: defaults.color ?? "#2962ff",
4504
4724
  style: defaults.style ?? "solid",
4505
- width: defaults.width ?? 1
4725
+ width: defaults.width ?? 1,
4726
+ // pointValue enables the dollar-P&L line in the measure label.
4727
+ ...defaults.pointValue === void 0 ? {} : { pointValue: defaults.pointValue }
4506
4728
  })
4507
4729
  );
4508
4730
  emitDrawingsChange();
@@ -215,6 +215,14 @@ interface GridOptions {
215
215
  xTickCount?: number;
216
216
  yTickCount?: number;
217
217
  horizontalTickCount?: number;
218
+ /**
219
+ * Vertical separator at every trading-day boundary (a bar whose UTC
220
+ * calendar day differs from the previous bar's). Drawn stronger than the
221
+ * regular grid so intraday charts stay oriented while scrolling history.
222
+ */
223
+ sessionSeparators?: boolean;
224
+ sessionSeparatorColor?: string;
225
+ sessionSeparatorOpacity?: number;
218
226
  }
219
227
  interface CrosshairOptions {
220
228
  visible?: boolean;
@@ -17,7 +17,10 @@ var DEFAULT_GRID_OPTIONS = {
17
17
  verticalLines: true,
18
18
  xTickCount: 8,
19
19
  yTickCount: 6,
20
- horizontalTickCount: 6
20
+ horizontalTickCount: 6,
21
+ sessionSeparators: false,
22
+ sessionSeparatorColor: "#3b4150",
23
+ sessionSeparatorOpacity: 0.55
21
24
  };
22
25
  var DEFAULT_AXIS_OPTIONS = {
23
26
  lineColor: "#3b3f47",
@@ -406,6 +409,60 @@ var computeStdDevSeries = (data, length, source) => {
406
409
  }
407
410
  return result;
408
411
  };
412
+ var VWAP_SESSION_ANCHOR_MS = 22 * 36e5;
413
+ var vwapSessionDayOf = (ms) => Math.floor((ms - VWAP_SESSION_ANCHOR_MS) / 864e5);
414
+ var computeVwapSeries = (data, band) => {
415
+ const result = new Array(data.length).fill(null);
416
+ let session = Number.NaN;
417
+ let cumPv = 0;
418
+ let cumPv2 = 0;
419
+ let cumV = 0;
420
+ for (let i = 0; i < data.length; i += 1) {
421
+ const point = data[i];
422
+ if (!point) continue;
423
+ const day = vwapSessionDayOf(point.time.getTime());
424
+ if (day !== session) {
425
+ session = day;
426
+ cumPv = 0;
427
+ cumPv2 = 0;
428
+ cumV = 0;
429
+ }
430
+ const typical = (point.h + point.l + point.c) / 3;
431
+ const volume = Math.max(0, point.v ?? 0);
432
+ cumPv += typical * volume;
433
+ cumPv2 += typical * typical * volume;
434
+ cumV += volume;
435
+ if (cumV <= 0) continue;
436
+ const vwap = cumPv / cumV;
437
+ if (band === "vwap") {
438
+ result[i] = vwap;
439
+ } else {
440
+ const variance = Math.max(0, cumPv2 / cumV - vwap * vwap);
441
+ result[i] = Math.sqrt(variance);
442
+ }
443
+ }
444
+ for (let i = 1; i < data.length; i += 1) {
445
+ const point = data[i];
446
+ const prev = data[i - 1];
447
+ const next = data[i + 1];
448
+ if (!point || !prev || !next) continue;
449
+ const day = vwapSessionDayOf(point.time.getTime());
450
+ if (day !== vwapSessionDayOf(prev.time.getTime()) && day === vwapSessionDayOf(next.time.getTime())) {
451
+ result[i] = null;
452
+ }
453
+ }
454
+ return result;
455
+ };
456
+ var computeBollingerSeries = (data, length, multiplier, source, band) => {
457
+ const basis = computeSmaSeries(data, length, source);
458
+ if (band === "basis") return basis;
459
+ const dev = computeStdDevSeries(data, length, source);
460
+ return basis.map((value, idx) => {
461
+ const sigma = dev[idx];
462
+ if (value == null || sigma == null) return null;
463
+ return band === "upper" ? value + multiplier * sigma : value - multiplier * sigma;
464
+ });
465
+ };
409
466
  var computeRsiSeries = (data, length) => {
410
467
  const result = new Array(data.length).fill(null);
411
468
  if (data.length < 2) return result;
@@ -532,6 +589,39 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
532
589
  }
533
590
  ctx.restore();
534
591
  };
592
+ var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
593
+ if (!renderContext.yFromPrice || opacity <= 0) return;
594
+ const yFromPrice = renderContext.yFromPrice;
595
+ ctx.save();
596
+ ctx.globalAlpha = Math.min(1, Math.max(0, opacity));
597
+ ctx.fillStyle = color;
598
+ let runStart = -1;
599
+ const flushRun = (endIndexExclusive) => {
600
+ if (runStart < 0) return;
601
+ ctx.beginPath();
602
+ for (let i = runStart; i < endIndexExclusive; i += 1) {
603
+ const x = renderContext.xFromIndex(i);
604
+ const y = yFromPrice(upper[i]);
605
+ if (i === runStart) ctx.moveTo(x, y);
606
+ else ctx.lineTo(x, y);
607
+ }
608
+ for (let i = endIndexExclusive - 1; i >= runStart; i -= 1) {
609
+ ctx.lineTo(renderContext.xFromIndex(i), yFromPrice(lower[i]));
610
+ }
611
+ ctx.closePath();
612
+ ctx.fill();
613
+ runStart = -1;
614
+ };
615
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
616
+ const up = upper[index];
617
+ const lo = lower[index];
618
+ const valid = Number.isFinite(up ?? Number.NaN) && Number.isFinite(lo ?? Number.NaN);
619
+ if (valid && runStart < 0) runStart = index;
620
+ if (!valid) flushRun(index);
621
+ }
622
+ flushRun(renderContext.endIndex + 1);
623
+ ctx.restore();
624
+ };
535
625
  var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines, options = {}) => {
536
626
  const visible = [];
537
627
  for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
@@ -793,6 +883,89 @@ var BUILTIN_HMA_INDICATOR = {
793
883
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
794
884
  }
795
885
  };
886
+ var BUILTIN_VWAP_INDICATOR = {
887
+ id: "vwap",
888
+ name: "VWAP",
889
+ pane: "overlay",
890
+ defaultInputs: {
891
+ color: "#f59e0b",
892
+ width: 2,
893
+ showBands: false,
894
+ bandMultiplier: 1,
895
+ bandColor: "#94a3b8",
896
+ bandFillOpacity: 0.06
897
+ },
898
+ draw: (ctx, renderContext, inputs) => {
899
+ const vwap = withCachedSeries(
900
+ "vwap|line",
901
+ renderContext.data,
902
+ () => computeVwapSeries(renderContext.data, "vwap")
903
+ );
904
+ if (inputs.showBands) {
905
+ const sigma = withCachedSeries(
906
+ "vwap|sigma",
907
+ renderContext.data,
908
+ () => computeVwapSeries(renderContext.data, "sigma")
909
+ );
910
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
911
+ const upper = vwap.map((value, idx) => {
912
+ const s = sigma[idx];
913
+ return value == null || s == null ? null : value + multiplier * s;
914
+ });
915
+ const lower = vwap.map((value, idx) => {
916
+ const s = sigma[idx];
917
+ return value == null || s == null ? null : value - multiplier * s;
918
+ });
919
+ const bandColor = inputs.bandColor ?? "#94a3b8";
920
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.bandFillOpacity) || 0.06);
921
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, 1);
922
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
923
+ }
924
+ drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
925
+ }
926
+ };
927
+ var BUILTIN_BOLLINGER_INDICATOR = {
928
+ id: "bollinger",
929
+ name: "BB",
930
+ pane: "overlay",
931
+ // Basis is pink so it stays distinguishable from the (orange) VWAP line
932
+ // when both overlays are active.
933
+ defaultInputs: {
934
+ length: 20,
935
+ multiplier: 2,
936
+ source: "close",
937
+ basisColor: "#ec4899",
938
+ bandColor: "#3b82f6",
939
+ width: 1.5,
940
+ fillOpacity: 0.05
941
+ },
942
+ draw: (ctx, renderContext, inputs) => {
943
+ const length = clampIndicatorLength(inputs.length, 20);
944
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
945
+ const source = inputs.source ?? "close";
946
+ const basis = withCachedSeries(
947
+ `bb|basis|${length}|${source}`,
948
+ renderContext.data,
949
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "basis")
950
+ );
951
+ const upper = withCachedSeries(
952
+ `bb|upper|${length}|${multiplier}|${source}`,
953
+ renderContext.data,
954
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "upper")
955
+ );
956
+ const lower = withCachedSeries(
957
+ `bb|lower|${length}|${multiplier}|${source}`,
958
+ renderContext.data,
959
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "lower")
960
+ );
961
+ const bandColor = inputs.bandColor ?? "#3b82f6";
962
+ const width = Number(inputs.width) || 1.5;
963
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
964
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
965
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
966
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
967
+ }
968
+ };
796
969
  var BUILTIN_STDDEV_INDICATOR = {
797
970
  id: "stddev",
798
971
  name: "StdDev",
@@ -876,6 +1049,8 @@ var BUILTIN_INDICATORS = [
876
1049
  BUILTIN_VWMA_INDICATOR,
877
1050
  BUILTIN_RMA_INDICATOR,
878
1051
  BUILTIN_HMA_INDICATOR,
1052
+ BUILTIN_VWAP_INDICATOR,
1053
+ BUILTIN_BOLLINGER_INDICATOR,
879
1054
  BUILTIN_STDDEV_INDICATOR,
880
1055
  BUILTIN_ATR_INDICATOR
881
1056
  ];
@@ -2728,15 +2903,31 @@ function createChart(element, options = {}) {
2728
2903
  const pct = diff / base * 100;
2729
2904
  const ticks = tick > 0 ? Math.round(diff / tick) : 0;
2730
2905
  const signed = (value, text) => `${value < 0 ? "\u2212" : value > 0 ? "+" : ""}${text}`;
2731
- const labelText = tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`;
2906
+ const labelLines = [
2907
+ tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`
2908
+ ];
2909
+ const barSpan = Math.abs(Math.round(p1.index) - Math.round(p0.index));
2910
+ if (barSpan > 0) {
2911
+ const t0 = getTimeForIndex(Math.round(p0.index));
2912
+ const t1 = getTimeForIndex(Math.round(p1.index));
2913
+ const spanMs = t0 && t1 ? Math.abs(t1.getTime() - t0.getTime()) : 0;
2914
+ labelLines.push(spanMs > 0 ? `${barSpan} bars, ${formatDuration(spanMs)}` : `${barSpan} bars`);
2915
+ }
2916
+ const pointValue = Number(drawing.pointValue);
2917
+ if (Number.isFinite(pointValue) && pointValue > 0) {
2918
+ const dollars = diff * pointValue;
2919
+ const money = Math.abs(dollars).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
2920
+ labelLines.push(`${signed(dollars, `$${money}`)} / contract`);
2921
+ }
2732
2922
  const prevFont = ctx.font;
2733
2923
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2734
2924
  const padding = 6;
2735
- const textW = ctx.measureText(labelText).width;
2925
+ const lineHeight = 15;
2926
+ const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
2736
2927
  const pillW = textW + padding * 2;
2737
- const pillH = 18;
2928
+ const pillH = labelLines.length * lineHeight + padding;
2738
2929
  const pillX = midX - pillW / 2;
2739
- const pillY = botY + 6;
2930
+ const pillY = botY + 6 + pillH <= fullChartBottom - 2 ? botY + 6 : Math.max(chartTop + 2, topY - 6 - pillH);
2740
2931
  ctx.fillStyle = mergedOptions.backgroundColor;
2741
2932
  fillRoundedRect(pillX, pillY, pillW, pillH, 4);
2742
2933
  ctx.save();
@@ -2747,7 +2938,9 @@ function createChart(element, options = {}) {
2747
2938
  ctx.fillStyle = drawing.color;
2748
2939
  ctx.textAlign = "center";
2749
2940
  ctx.textBaseline = "middle";
2750
- ctx.fillText(labelText, midX, pillY + pillH / 2);
2941
+ labelLines.forEach((line, lineIndex) => {
2942
+ ctx.fillText(line, midX, pillY + padding / 2 + lineIndex * lineHeight + lineHeight / 2);
2943
+ });
2751
2944
  ctx.font = prevFont;
2752
2945
  if (drawing.label) {
2753
2946
  drawDrawingLabel(drawing.label, midX, topY - 4, drawing.color);
@@ -2891,6 +3084,32 @@ function createChart(element, options = {}) {
2891
3084
  }
2892
3085
  ctx.restore();
2893
3086
  }
3087
+ if (grid.sessionSeparators && data.length > 1) {
3088
+ const first = Math.max(1, startIndex);
3089
+ const barDeltaMs = data.length > 1 ? Math.abs(data[Math.min(first, data.length - 1)].time.getTime() - data[Math.min(first, data.length - 1) - 1].time.getTime()) : 0;
3090
+ if (barDeltaMs > 0 && barDeltaMs < 0.9 * 864e5) {
3091
+ const SESSION_ANCHOR_MS = 22 * 36e5;
3092
+ const sessionDayOf = (ms) => Math.floor((ms - SESSION_ANCHOR_MS) / 864e5);
3093
+ ctx.save();
3094
+ ctx.globalAlpha = clamp(grid.sessionSeparatorOpacity, 0, 1);
3095
+ ctx.strokeStyle = grid.sessionSeparatorColor;
3096
+ ctx.lineWidth = 1;
3097
+ for (let index = first; index <= endIndex; index += 1) {
3098
+ const point = data[index];
3099
+ const prev = data[index - 1];
3100
+ if (!point || !prev) continue;
3101
+ if (sessionDayOf(point.time.getTime()) === sessionDayOf(prev.time.getTime())) {
3102
+ continue;
3103
+ }
3104
+ const x = chartLeft + (index - xStart) / xSpan * chartWidth;
3105
+ ctx.beginPath();
3106
+ ctx.moveTo(crisp(x), crisp(chartTop));
3107
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
3108
+ ctx.stroke();
3109
+ }
3110
+ ctx.restore();
3111
+ }
3112
+ }
2894
3113
  const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
2895
3114
  const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
2896
3115
  const lastDataIndex = data.length - 1;
@@ -3497,8 +3716,9 @@ function createChart(element, options = {}) {
3497
3716
  }
3498
3717
  return typeof value === "string" && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value.trim());
3499
3718
  };
3719
+ const LEGEND_EXCLUDED_INPUT_KEYS = /* @__PURE__ */ new Set(["width", "bandMultiplier", "bandFillOpacity", "fillOpacity"]);
3500
3720
  const labelEntries = activeOverlayIndicators.map(({ indicator, plugin }) => {
3501
- const inputValues = Object.entries(indicator.inputs).filter(([, value]) => isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3721
+ const inputValues = Object.entries(indicator.inputs).filter(([key, value]) => !LEGEND_EXCLUDED_INPUT_KEYS.has(key) && typeof value !== "boolean" && isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3502
3722
  if (labels.showIndicatorNames && labels.showIndicatorValues && inputValues.length > 0) {
3503
3723
  return `${plugin.name} ${inputValues.join(" ")}`;
3504
3724
  }
@@ -4476,7 +4696,9 @@ function createChart(element, options = {}) {
4476
4696
  points: [point, normalizeDrawingPoint(point.index + width2, point.price - priceOffset)],
4477
4697
  color: defaults.color ?? "#2962ff",
4478
4698
  style: defaults.style ?? "solid",
4479
- width: defaults.width ?? 1
4699
+ width: defaults.width ?? 1,
4700
+ // pointValue enables the dollar-P&L line in the measure label.
4701
+ ...defaults.pointValue === void 0 ? {} : { pointValue: defaults.pointValue }
4480
4702
  })
4481
4703
  );
4482
4704
  emitDrawingsChange();
package/dist/index.cjs CHANGED
@@ -43,7 +43,10 @@ var DEFAULT_GRID_OPTIONS = {
43
43
  verticalLines: true,
44
44
  xTickCount: 8,
45
45
  yTickCount: 6,
46
- horizontalTickCount: 6
46
+ horizontalTickCount: 6,
47
+ sessionSeparators: false,
48
+ sessionSeparatorColor: "#3b4150",
49
+ sessionSeparatorOpacity: 0.55
47
50
  };
48
51
  var DEFAULT_AXIS_OPTIONS = {
49
52
  lineColor: "#3b3f47",
@@ -432,6 +435,60 @@ var computeStdDevSeries = (data, length, source) => {
432
435
  }
433
436
  return result;
434
437
  };
438
+ var VWAP_SESSION_ANCHOR_MS = 22 * 36e5;
439
+ var vwapSessionDayOf = (ms) => Math.floor((ms - VWAP_SESSION_ANCHOR_MS) / 864e5);
440
+ var computeVwapSeries = (data, band) => {
441
+ const result = new Array(data.length).fill(null);
442
+ let session = Number.NaN;
443
+ let cumPv = 0;
444
+ let cumPv2 = 0;
445
+ let cumV = 0;
446
+ for (let i = 0; i < data.length; i += 1) {
447
+ const point = data[i];
448
+ if (!point) continue;
449
+ const day = vwapSessionDayOf(point.time.getTime());
450
+ if (day !== session) {
451
+ session = day;
452
+ cumPv = 0;
453
+ cumPv2 = 0;
454
+ cumV = 0;
455
+ }
456
+ const typical = (point.h + point.l + point.c) / 3;
457
+ const volume = Math.max(0, point.v ?? 0);
458
+ cumPv += typical * volume;
459
+ cumPv2 += typical * typical * volume;
460
+ cumV += volume;
461
+ if (cumV <= 0) continue;
462
+ const vwap = cumPv / cumV;
463
+ if (band === "vwap") {
464
+ result[i] = vwap;
465
+ } else {
466
+ const variance = Math.max(0, cumPv2 / cumV - vwap * vwap);
467
+ result[i] = Math.sqrt(variance);
468
+ }
469
+ }
470
+ for (let i = 1; i < data.length; i += 1) {
471
+ const point = data[i];
472
+ const prev = data[i - 1];
473
+ const next = data[i + 1];
474
+ if (!point || !prev || !next) continue;
475
+ const day = vwapSessionDayOf(point.time.getTime());
476
+ if (day !== vwapSessionDayOf(prev.time.getTime()) && day === vwapSessionDayOf(next.time.getTime())) {
477
+ result[i] = null;
478
+ }
479
+ }
480
+ return result;
481
+ };
482
+ var computeBollingerSeries = (data, length, multiplier, source, band) => {
483
+ const basis = computeSmaSeries(data, length, source);
484
+ if (band === "basis") return basis;
485
+ const dev = computeStdDevSeries(data, length, source);
486
+ return basis.map((value, idx) => {
487
+ const sigma = dev[idx];
488
+ if (value == null || sigma == null) return null;
489
+ return band === "upper" ? value + multiplier * sigma : value - multiplier * sigma;
490
+ });
491
+ };
435
492
  var computeRsiSeries = (data, length) => {
436
493
  const result = new Array(data.length).fill(null);
437
494
  if (data.length < 2) return result;
@@ -558,6 +615,39 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
558
615
  }
559
616
  ctx.restore();
560
617
  };
618
+ var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
619
+ if (!renderContext.yFromPrice || opacity <= 0) return;
620
+ const yFromPrice = renderContext.yFromPrice;
621
+ ctx.save();
622
+ ctx.globalAlpha = Math.min(1, Math.max(0, opacity));
623
+ ctx.fillStyle = color;
624
+ let runStart = -1;
625
+ const flushRun = (endIndexExclusive) => {
626
+ if (runStart < 0) return;
627
+ ctx.beginPath();
628
+ for (let i = runStart; i < endIndexExclusive; i += 1) {
629
+ const x = renderContext.xFromIndex(i);
630
+ const y = yFromPrice(upper[i]);
631
+ if (i === runStart) ctx.moveTo(x, y);
632
+ else ctx.lineTo(x, y);
633
+ }
634
+ for (let i = endIndexExclusive - 1; i >= runStart; i -= 1) {
635
+ ctx.lineTo(renderContext.xFromIndex(i), yFromPrice(lower[i]));
636
+ }
637
+ ctx.closePath();
638
+ ctx.fill();
639
+ runStart = -1;
640
+ };
641
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
642
+ const up = upper[index];
643
+ const lo = lower[index];
644
+ const valid = Number.isFinite(up ?? Number.NaN) && Number.isFinite(lo ?? Number.NaN);
645
+ if (valid && runStart < 0) runStart = index;
646
+ if (!valid) flushRun(index);
647
+ }
648
+ flushRun(renderContext.endIndex + 1);
649
+ ctx.restore();
650
+ };
561
651
  var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines, options = {}) => {
562
652
  const visible = [];
563
653
  for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
@@ -819,6 +909,89 @@ var BUILTIN_HMA_INDICATOR = {
819
909
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
820
910
  }
821
911
  };
912
+ var BUILTIN_VWAP_INDICATOR = {
913
+ id: "vwap",
914
+ name: "VWAP",
915
+ pane: "overlay",
916
+ defaultInputs: {
917
+ color: "#f59e0b",
918
+ width: 2,
919
+ showBands: false,
920
+ bandMultiplier: 1,
921
+ bandColor: "#94a3b8",
922
+ bandFillOpacity: 0.06
923
+ },
924
+ draw: (ctx, renderContext, inputs) => {
925
+ const vwap = withCachedSeries(
926
+ "vwap|line",
927
+ renderContext.data,
928
+ () => computeVwapSeries(renderContext.data, "vwap")
929
+ );
930
+ if (inputs.showBands) {
931
+ const sigma = withCachedSeries(
932
+ "vwap|sigma",
933
+ renderContext.data,
934
+ () => computeVwapSeries(renderContext.data, "sigma")
935
+ );
936
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
937
+ const upper = vwap.map((value, idx) => {
938
+ const s = sigma[idx];
939
+ return value == null || s == null ? null : value + multiplier * s;
940
+ });
941
+ const lower = vwap.map((value, idx) => {
942
+ const s = sigma[idx];
943
+ return value == null || s == null ? null : value - multiplier * s;
944
+ });
945
+ const bandColor = inputs.bandColor ?? "#94a3b8";
946
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.bandFillOpacity) || 0.06);
947
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, 1);
948
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
949
+ }
950
+ drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
951
+ }
952
+ };
953
+ var BUILTIN_BOLLINGER_INDICATOR = {
954
+ id: "bollinger",
955
+ name: "BB",
956
+ pane: "overlay",
957
+ // Basis is pink so it stays distinguishable from the (orange) VWAP line
958
+ // when both overlays are active.
959
+ defaultInputs: {
960
+ length: 20,
961
+ multiplier: 2,
962
+ source: "close",
963
+ basisColor: "#ec4899",
964
+ bandColor: "#3b82f6",
965
+ width: 1.5,
966
+ fillOpacity: 0.05
967
+ },
968
+ draw: (ctx, renderContext, inputs) => {
969
+ const length = clampIndicatorLength(inputs.length, 20);
970
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
971
+ const source = inputs.source ?? "close";
972
+ const basis = withCachedSeries(
973
+ `bb|basis|${length}|${source}`,
974
+ renderContext.data,
975
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "basis")
976
+ );
977
+ const upper = withCachedSeries(
978
+ `bb|upper|${length}|${multiplier}|${source}`,
979
+ renderContext.data,
980
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "upper")
981
+ );
982
+ const lower = withCachedSeries(
983
+ `bb|lower|${length}|${multiplier}|${source}`,
984
+ renderContext.data,
985
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "lower")
986
+ );
987
+ const bandColor = inputs.bandColor ?? "#3b82f6";
988
+ const width = Number(inputs.width) || 1.5;
989
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
990
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
991
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
992
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
993
+ }
994
+ };
822
995
  var BUILTIN_STDDEV_INDICATOR = {
823
996
  id: "stddev",
824
997
  name: "StdDev",
@@ -902,6 +1075,8 @@ var BUILTIN_INDICATORS = [
902
1075
  BUILTIN_VWMA_INDICATOR,
903
1076
  BUILTIN_RMA_INDICATOR,
904
1077
  BUILTIN_HMA_INDICATOR,
1078
+ BUILTIN_VWAP_INDICATOR,
1079
+ BUILTIN_BOLLINGER_INDICATOR,
905
1080
  BUILTIN_STDDEV_INDICATOR,
906
1081
  BUILTIN_ATR_INDICATOR
907
1082
  ];
@@ -2754,15 +2929,31 @@ function createChart(element, options = {}) {
2754
2929
  const pct = diff / base * 100;
2755
2930
  const ticks = tick > 0 ? Math.round(diff / tick) : 0;
2756
2931
  const signed = (value, text) => `${value < 0 ? "\u2212" : value > 0 ? "+" : ""}${text}`;
2757
- const labelText = tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`;
2932
+ const labelLines = [
2933
+ tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`
2934
+ ];
2935
+ const barSpan = Math.abs(Math.round(p1.index) - Math.round(p0.index));
2936
+ if (barSpan > 0) {
2937
+ const t0 = getTimeForIndex(Math.round(p0.index));
2938
+ const t1 = getTimeForIndex(Math.round(p1.index));
2939
+ const spanMs = t0 && t1 ? Math.abs(t1.getTime() - t0.getTime()) : 0;
2940
+ labelLines.push(spanMs > 0 ? `${barSpan} bars, ${formatDuration(spanMs)}` : `${barSpan} bars`);
2941
+ }
2942
+ const pointValue = Number(drawing.pointValue);
2943
+ if (Number.isFinite(pointValue) && pointValue > 0) {
2944
+ const dollars = diff * pointValue;
2945
+ const money = Math.abs(dollars).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
2946
+ labelLines.push(`${signed(dollars, `$${money}`)} / contract`);
2947
+ }
2758
2948
  const prevFont = ctx.font;
2759
2949
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2760
2950
  const padding = 6;
2761
- const textW = ctx.measureText(labelText).width;
2951
+ const lineHeight = 15;
2952
+ const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
2762
2953
  const pillW = textW + padding * 2;
2763
- const pillH = 18;
2954
+ const pillH = labelLines.length * lineHeight + padding;
2764
2955
  const pillX = midX - pillW / 2;
2765
- const pillY = botY + 6;
2956
+ const pillY = botY + 6 + pillH <= fullChartBottom - 2 ? botY + 6 : Math.max(chartTop + 2, topY - 6 - pillH);
2766
2957
  ctx.fillStyle = mergedOptions.backgroundColor;
2767
2958
  fillRoundedRect(pillX, pillY, pillW, pillH, 4);
2768
2959
  ctx.save();
@@ -2773,7 +2964,9 @@ function createChart(element, options = {}) {
2773
2964
  ctx.fillStyle = drawing.color;
2774
2965
  ctx.textAlign = "center";
2775
2966
  ctx.textBaseline = "middle";
2776
- ctx.fillText(labelText, midX, pillY + pillH / 2);
2967
+ labelLines.forEach((line, lineIndex) => {
2968
+ ctx.fillText(line, midX, pillY + padding / 2 + lineIndex * lineHeight + lineHeight / 2);
2969
+ });
2777
2970
  ctx.font = prevFont;
2778
2971
  if (drawing.label) {
2779
2972
  drawDrawingLabel(drawing.label, midX, topY - 4, drawing.color);
@@ -2917,6 +3110,32 @@ function createChart(element, options = {}) {
2917
3110
  }
2918
3111
  ctx.restore();
2919
3112
  }
3113
+ if (grid.sessionSeparators && data.length > 1) {
3114
+ const first = Math.max(1, startIndex);
3115
+ const barDeltaMs = data.length > 1 ? Math.abs(data[Math.min(first, data.length - 1)].time.getTime() - data[Math.min(first, data.length - 1) - 1].time.getTime()) : 0;
3116
+ if (barDeltaMs > 0 && barDeltaMs < 0.9 * 864e5) {
3117
+ const SESSION_ANCHOR_MS = 22 * 36e5;
3118
+ const sessionDayOf = (ms) => Math.floor((ms - SESSION_ANCHOR_MS) / 864e5);
3119
+ ctx.save();
3120
+ ctx.globalAlpha = clamp(grid.sessionSeparatorOpacity, 0, 1);
3121
+ ctx.strokeStyle = grid.sessionSeparatorColor;
3122
+ ctx.lineWidth = 1;
3123
+ for (let index = first; index <= endIndex; index += 1) {
3124
+ const point = data[index];
3125
+ const prev = data[index - 1];
3126
+ if (!point || !prev) continue;
3127
+ if (sessionDayOf(point.time.getTime()) === sessionDayOf(prev.time.getTime())) {
3128
+ continue;
3129
+ }
3130
+ const x = chartLeft + (index - xStart) / xSpan * chartWidth;
3131
+ ctx.beginPath();
3132
+ ctx.moveTo(crisp(x), crisp(chartTop));
3133
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
3134
+ ctx.stroke();
3135
+ }
3136
+ ctx.restore();
3137
+ }
3138
+ }
2920
3139
  const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
2921
3140
  const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
2922
3141
  const lastDataIndex = data.length - 1;
@@ -3523,8 +3742,9 @@ function createChart(element, options = {}) {
3523
3742
  }
3524
3743
  return typeof value === "string" && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value.trim());
3525
3744
  };
3745
+ const LEGEND_EXCLUDED_INPUT_KEYS = /* @__PURE__ */ new Set(["width", "bandMultiplier", "bandFillOpacity", "fillOpacity"]);
3526
3746
  const labelEntries = activeOverlayIndicators.map(({ indicator, plugin }) => {
3527
- const inputValues = Object.entries(indicator.inputs).filter(([, value]) => isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3747
+ const inputValues = Object.entries(indicator.inputs).filter(([key, value]) => !LEGEND_EXCLUDED_INPUT_KEYS.has(key) && typeof value !== "boolean" && isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3528
3748
  if (labels.showIndicatorNames && labels.showIndicatorValues && inputValues.length > 0) {
3529
3749
  return `${plugin.name} ${inputValues.join(" ")}`;
3530
3750
  }
@@ -4502,7 +4722,9 @@ function createChart(element, options = {}) {
4502
4722
  points: [point, normalizeDrawingPoint(point.index + width2, point.price - priceOffset)],
4503
4723
  color: defaults.color ?? "#2962ff",
4504
4724
  style: defaults.style ?? "solid",
4505
- width: defaults.width ?? 1
4725
+ width: defaults.width ?? 1,
4726
+ // pointValue enables the dollar-P&L line in the measure label.
4727
+ ...defaults.pointValue === void 0 ? {} : { pointValue: defaults.pointValue }
4506
4728
  })
4507
4729
  );
4508
4730
  emitDrawingsChange();
package/dist/index.d.cts CHANGED
@@ -215,6 +215,14 @@ interface GridOptions {
215
215
  xTickCount?: number;
216
216
  yTickCount?: number;
217
217
  horizontalTickCount?: number;
218
+ /**
219
+ * Vertical separator at every trading-day boundary (a bar whose UTC
220
+ * calendar day differs from the previous bar's). Drawn stronger than the
221
+ * regular grid so intraday charts stay oriented while scrolling history.
222
+ */
223
+ sessionSeparators?: boolean;
224
+ sessionSeparatorColor?: string;
225
+ sessionSeparatorOpacity?: number;
218
226
  }
219
227
  interface CrosshairOptions {
220
228
  visible?: boolean;
package/dist/index.d.ts CHANGED
@@ -215,6 +215,14 @@ interface GridOptions {
215
215
  xTickCount?: number;
216
216
  yTickCount?: number;
217
217
  horizontalTickCount?: number;
218
+ /**
219
+ * Vertical separator at every trading-day boundary (a bar whose UTC
220
+ * calendar day differs from the previous bar's). Drawn stronger than the
221
+ * regular grid so intraday charts stay oriented while scrolling history.
222
+ */
223
+ sessionSeparators?: boolean;
224
+ sessionSeparatorColor?: string;
225
+ sessionSeparatorOpacity?: number;
218
226
  }
219
227
  interface CrosshairOptions {
220
228
  visible?: boolean;
package/dist/index.js CHANGED
@@ -17,7 +17,10 @@ var DEFAULT_GRID_OPTIONS = {
17
17
  verticalLines: true,
18
18
  xTickCount: 8,
19
19
  yTickCount: 6,
20
- horizontalTickCount: 6
20
+ horizontalTickCount: 6,
21
+ sessionSeparators: false,
22
+ sessionSeparatorColor: "#3b4150",
23
+ sessionSeparatorOpacity: 0.55
21
24
  };
22
25
  var DEFAULT_AXIS_OPTIONS = {
23
26
  lineColor: "#3b3f47",
@@ -406,6 +409,60 @@ var computeStdDevSeries = (data, length, source) => {
406
409
  }
407
410
  return result;
408
411
  };
412
+ var VWAP_SESSION_ANCHOR_MS = 22 * 36e5;
413
+ var vwapSessionDayOf = (ms) => Math.floor((ms - VWAP_SESSION_ANCHOR_MS) / 864e5);
414
+ var computeVwapSeries = (data, band) => {
415
+ const result = new Array(data.length).fill(null);
416
+ let session = Number.NaN;
417
+ let cumPv = 0;
418
+ let cumPv2 = 0;
419
+ let cumV = 0;
420
+ for (let i = 0; i < data.length; i += 1) {
421
+ const point = data[i];
422
+ if (!point) continue;
423
+ const day = vwapSessionDayOf(point.time.getTime());
424
+ if (day !== session) {
425
+ session = day;
426
+ cumPv = 0;
427
+ cumPv2 = 0;
428
+ cumV = 0;
429
+ }
430
+ const typical = (point.h + point.l + point.c) / 3;
431
+ const volume = Math.max(0, point.v ?? 0);
432
+ cumPv += typical * volume;
433
+ cumPv2 += typical * typical * volume;
434
+ cumV += volume;
435
+ if (cumV <= 0) continue;
436
+ const vwap = cumPv / cumV;
437
+ if (band === "vwap") {
438
+ result[i] = vwap;
439
+ } else {
440
+ const variance = Math.max(0, cumPv2 / cumV - vwap * vwap);
441
+ result[i] = Math.sqrt(variance);
442
+ }
443
+ }
444
+ for (let i = 1; i < data.length; i += 1) {
445
+ const point = data[i];
446
+ const prev = data[i - 1];
447
+ const next = data[i + 1];
448
+ if (!point || !prev || !next) continue;
449
+ const day = vwapSessionDayOf(point.time.getTime());
450
+ if (day !== vwapSessionDayOf(prev.time.getTime()) && day === vwapSessionDayOf(next.time.getTime())) {
451
+ result[i] = null;
452
+ }
453
+ }
454
+ return result;
455
+ };
456
+ var computeBollingerSeries = (data, length, multiplier, source, band) => {
457
+ const basis = computeSmaSeries(data, length, source);
458
+ if (band === "basis") return basis;
459
+ const dev = computeStdDevSeries(data, length, source);
460
+ return basis.map((value, idx) => {
461
+ const sigma = dev[idx];
462
+ if (value == null || sigma == null) return null;
463
+ return band === "upper" ? value + multiplier * sigma : value - multiplier * sigma;
464
+ });
465
+ };
409
466
  var computeRsiSeries = (data, length) => {
410
467
  const result = new Array(data.length).fill(null);
411
468
  if (data.length < 2) return result;
@@ -532,6 +589,39 @@ var drawOverlaySeries = (ctx, renderContext, values, color, width) => {
532
589
  }
533
590
  ctx.restore();
534
591
  };
592
+ var fillBetweenSeries = (ctx, renderContext, upper, lower, color, opacity) => {
593
+ if (!renderContext.yFromPrice || opacity <= 0) return;
594
+ const yFromPrice = renderContext.yFromPrice;
595
+ ctx.save();
596
+ ctx.globalAlpha = Math.min(1, Math.max(0, opacity));
597
+ ctx.fillStyle = color;
598
+ let runStart = -1;
599
+ const flushRun = (endIndexExclusive) => {
600
+ if (runStart < 0) return;
601
+ ctx.beginPath();
602
+ for (let i = runStart; i < endIndexExclusive; i += 1) {
603
+ const x = renderContext.xFromIndex(i);
604
+ const y = yFromPrice(upper[i]);
605
+ if (i === runStart) ctx.moveTo(x, y);
606
+ else ctx.lineTo(x, y);
607
+ }
608
+ for (let i = endIndexExclusive - 1; i >= runStart; i -= 1) {
609
+ ctx.lineTo(renderContext.xFromIndex(i), yFromPrice(lower[i]));
610
+ }
611
+ ctx.closePath();
612
+ ctx.fill();
613
+ runStart = -1;
614
+ };
615
+ for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
616
+ const up = upper[index];
617
+ const lo = lower[index];
618
+ const valid = Number.isFinite(up ?? Number.NaN) && Number.isFinite(lo ?? Number.NaN);
619
+ if (valid && runStart < 0) runStart = index;
620
+ if (!valid) flushRun(index);
621
+ }
622
+ flushRun(renderContext.endIndex + 1);
623
+ ctx.restore();
624
+ };
535
625
  var drawSeparateSeries = (ctx, renderContext, values, color, width, minOverride, maxOverride, guideLines, options = {}) => {
536
626
  const visible = [];
537
627
  for (let index = renderContext.startIndex; index <= renderContext.endIndex; index += 1) {
@@ -793,6 +883,89 @@ var BUILTIN_HMA_INDICATOR = {
793
883
  drawOverlaySeries(ctx, renderContext, values, inputs.color ?? "#14b8a6", Number(inputs.width) || 2);
794
884
  }
795
885
  };
886
+ var BUILTIN_VWAP_INDICATOR = {
887
+ id: "vwap",
888
+ name: "VWAP",
889
+ pane: "overlay",
890
+ defaultInputs: {
891
+ color: "#f59e0b",
892
+ width: 2,
893
+ showBands: false,
894
+ bandMultiplier: 1,
895
+ bandColor: "#94a3b8",
896
+ bandFillOpacity: 0.06
897
+ },
898
+ draw: (ctx, renderContext, inputs) => {
899
+ const vwap = withCachedSeries(
900
+ "vwap|line",
901
+ renderContext.data,
902
+ () => computeVwapSeries(renderContext.data, "vwap")
903
+ );
904
+ if (inputs.showBands) {
905
+ const sigma = withCachedSeries(
906
+ "vwap|sigma",
907
+ renderContext.data,
908
+ () => computeVwapSeries(renderContext.data, "sigma")
909
+ );
910
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
911
+ const upper = vwap.map((value, idx) => {
912
+ const s = sigma[idx];
913
+ return value == null || s == null ? null : value + multiplier * s;
914
+ });
915
+ const lower = vwap.map((value, idx) => {
916
+ const s = sigma[idx];
917
+ return value == null || s == null ? null : value - multiplier * s;
918
+ });
919
+ const bandColor = inputs.bandColor ?? "#94a3b8";
920
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.bandFillOpacity) || 0.06);
921
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, 1);
922
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
923
+ }
924
+ drawOverlaySeries(ctx, renderContext, vwap, inputs.color ?? "#f59e0b", Number(inputs.width) || 2);
925
+ }
926
+ };
927
+ var BUILTIN_BOLLINGER_INDICATOR = {
928
+ id: "bollinger",
929
+ name: "BB",
930
+ pane: "overlay",
931
+ // Basis is pink so it stays distinguishable from the (orange) VWAP line
932
+ // when both overlays are active.
933
+ defaultInputs: {
934
+ length: 20,
935
+ multiplier: 2,
936
+ source: "close",
937
+ basisColor: "#ec4899",
938
+ bandColor: "#3b82f6",
939
+ width: 1.5,
940
+ fillOpacity: 0.05
941
+ },
942
+ draw: (ctx, renderContext, inputs) => {
943
+ const length = clampIndicatorLength(inputs.length, 20);
944
+ const multiplier = Math.max(0.1, Number(inputs.multiplier) || 2);
945
+ const source = inputs.source ?? "close";
946
+ const basis = withCachedSeries(
947
+ `bb|basis|${length}|${source}`,
948
+ renderContext.data,
949
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "basis")
950
+ );
951
+ const upper = withCachedSeries(
952
+ `bb|upper|${length}|${multiplier}|${source}`,
953
+ renderContext.data,
954
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "upper")
955
+ );
956
+ const lower = withCachedSeries(
957
+ `bb|lower|${length}|${multiplier}|${source}`,
958
+ renderContext.data,
959
+ () => computeBollingerSeries(renderContext.data, length, multiplier, source, "lower")
960
+ );
961
+ const bandColor = inputs.bandColor ?? "#3b82f6";
962
+ const width = Number(inputs.width) || 1.5;
963
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.fillOpacity) || 0.05);
964
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, width);
965
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, width);
966
+ drawOverlaySeries(ctx, renderContext, basis, inputs.basisColor ?? "#f59e0b", width);
967
+ }
968
+ };
796
969
  var BUILTIN_STDDEV_INDICATOR = {
797
970
  id: "stddev",
798
971
  name: "StdDev",
@@ -876,6 +1049,8 @@ var BUILTIN_INDICATORS = [
876
1049
  BUILTIN_VWMA_INDICATOR,
877
1050
  BUILTIN_RMA_INDICATOR,
878
1051
  BUILTIN_HMA_INDICATOR,
1052
+ BUILTIN_VWAP_INDICATOR,
1053
+ BUILTIN_BOLLINGER_INDICATOR,
879
1054
  BUILTIN_STDDEV_INDICATOR,
880
1055
  BUILTIN_ATR_INDICATOR
881
1056
  ];
@@ -2728,15 +2903,31 @@ function createChart(element, options = {}) {
2728
2903
  const pct = diff / base * 100;
2729
2904
  const ticks = tick > 0 ? Math.round(diff / tick) : 0;
2730
2905
  const signed = (value, text) => `${value < 0 ? "\u2212" : value > 0 ? "+" : ""}${text}`;
2731
- const labelText = tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`;
2906
+ const labelLines = [
2907
+ tick > 0 ? `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)}) ${signed(ticks, String(Math.abs(ticks)))}` : `${signed(diff, formatPrice(Math.abs(diff)))} (${signed(pct, `${Math.abs(pct).toFixed(2)}%`)})`
2908
+ ];
2909
+ const barSpan = Math.abs(Math.round(p1.index) - Math.round(p0.index));
2910
+ if (barSpan > 0) {
2911
+ const t0 = getTimeForIndex(Math.round(p0.index));
2912
+ const t1 = getTimeForIndex(Math.round(p1.index));
2913
+ const spanMs = t0 && t1 ? Math.abs(t1.getTime() - t0.getTime()) : 0;
2914
+ labelLines.push(spanMs > 0 ? `${barSpan} bars, ${formatDuration(spanMs)}` : `${barSpan} bars`);
2915
+ }
2916
+ const pointValue = Number(drawing.pointValue);
2917
+ if (Number.isFinite(pointValue) && pointValue > 0) {
2918
+ const dollars = diff * pointValue;
2919
+ const money = Math.abs(dollars).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
2920
+ labelLines.push(`${signed(dollars, `$${money}`)} / contract`);
2921
+ }
2732
2922
  const prevFont = ctx.font;
2733
2923
  ctx.font = `500 11px ${mergedOptions.fontFamily}`;
2734
2924
  const padding = 6;
2735
- const textW = ctx.measureText(labelText).width;
2925
+ const lineHeight = 15;
2926
+ const textW = Math.max(...labelLines.map((line) => ctx.measureText(line).width));
2736
2927
  const pillW = textW + padding * 2;
2737
- const pillH = 18;
2928
+ const pillH = labelLines.length * lineHeight + padding;
2738
2929
  const pillX = midX - pillW / 2;
2739
- const pillY = botY + 6;
2930
+ const pillY = botY + 6 + pillH <= fullChartBottom - 2 ? botY + 6 : Math.max(chartTop + 2, topY - 6 - pillH);
2740
2931
  ctx.fillStyle = mergedOptions.backgroundColor;
2741
2932
  fillRoundedRect(pillX, pillY, pillW, pillH, 4);
2742
2933
  ctx.save();
@@ -2747,7 +2938,9 @@ function createChart(element, options = {}) {
2747
2938
  ctx.fillStyle = drawing.color;
2748
2939
  ctx.textAlign = "center";
2749
2940
  ctx.textBaseline = "middle";
2750
- ctx.fillText(labelText, midX, pillY + pillH / 2);
2941
+ labelLines.forEach((line, lineIndex) => {
2942
+ ctx.fillText(line, midX, pillY + padding / 2 + lineIndex * lineHeight + lineHeight / 2);
2943
+ });
2751
2944
  ctx.font = prevFont;
2752
2945
  if (drawing.label) {
2753
2946
  drawDrawingLabel(drawing.label, midX, topY - 4, drawing.color);
@@ -2891,6 +3084,32 @@ function createChart(element, options = {}) {
2891
3084
  }
2892
3085
  ctx.restore();
2893
3086
  }
3087
+ if (grid.sessionSeparators && data.length > 1) {
3088
+ const first = Math.max(1, startIndex);
3089
+ const barDeltaMs = data.length > 1 ? Math.abs(data[Math.min(first, data.length - 1)].time.getTime() - data[Math.min(first, data.length - 1) - 1].time.getTime()) : 0;
3090
+ if (barDeltaMs > 0 && barDeltaMs < 0.9 * 864e5) {
3091
+ const SESSION_ANCHOR_MS = 22 * 36e5;
3092
+ const sessionDayOf = (ms) => Math.floor((ms - SESSION_ANCHOR_MS) / 864e5);
3093
+ ctx.save();
3094
+ ctx.globalAlpha = clamp(grid.sessionSeparatorOpacity, 0, 1);
3095
+ ctx.strokeStyle = grid.sessionSeparatorColor;
3096
+ ctx.lineWidth = 1;
3097
+ for (let index = first; index <= endIndex; index += 1) {
3098
+ const point = data[index];
3099
+ const prev = data[index - 1];
3100
+ if (!point || !prev) continue;
3101
+ if (sessionDayOf(point.time.getTime()) === sessionDayOf(prev.time.getTime())) {
3102
+ continue;
3103
+ }
3104
+ const x = chartLeft + (index - xStart) / xSpan * chartWidth;
3105
+ ctx.beginPath();
3106
+ ctx.moveTo(crisp(x), crisp(chartTop));
3107
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
3108
+ ctx.stroke();
3109
+ }
3110
+ ctx.restore();
3111
+ }
3112
+ }
2894
3113
  const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
2895
3114
  const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
2896
3115
  const lastDataIndex = data.length - 1;
@@ -3497,8 +3716,9 @@ function createChart(element, options = {}) {
3497
3716
  }
3498
3717
  return typeof value === "string" && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value.trim());
3499
3718
  };
3719
+ const LEGEND_EXCLUDED_INPUT_KEYS = /* @__PURE__ */ new Set(["width", "bandMultiplier", "bandFillOpacity", "fillOpacity"]);
3500
3720
  const labelEntries = activeOverlayIndicators.map(({ indicator, plugin }) => {
3501
- const inputValues = Object.entries(indicator.inputs).filter(([, value]) => isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3721
+ const inputValues = Object.entries(indicator.inputs).filter(([key, value]) => !LEGEND_EXCLUDED_INPUT_KEYS.has(key) && typeof value !== "boolean" && isLegendInputValue(value)).slice(0, 2).map(([, value]) => String(value));
3502
3722
  if (labels.showIndicatorNames && labels.showIndicatorValues && inputValues.length > 0) {
3503
3723
  return `${plugin.name} ${inputValues.join(" ")}`;
3504
3724
  }
@@ -4476,7 +4696,9 @@ function createChart(element, options = {}) {
4476
4696
  points: [point, normalizeDrawingPoint(point.index + width2, point.price - priceOffset)],
4477
4697
  color: defaults.color ?? "#2962ff",
4478
4698
  style: defaults.style ?? "solid",
4479
- width: defaults.width ?? 1
4699
+ width: defaults.width ?? 1,
4700
+ // pointValue enables the dollar-P&L line in the measure label.
4701
+ ...defaults.pointValue === void 0 ? {} : { pointValue: defaults.pointValue }
4480
4702
  })
4481
4703
  );
4482
4704
  emitDrawingsChange();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperprop-charting-library",
3
- "version": "0.1.118",
3
+ "version": "0.1.120",
4
4
  "description": "Lightweight TypeScript charting core",
5
5
  "type": "module",
6
6
  "main": "./dist/hyperprop-charting-library.cjs",