hyperprop-charting-library 0.1.145 → 0.1.146

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/dist/index.js CHANGED
@@ -2035,6 +2035,493 @@ var BUILTIN_DONCHIAN_INDICATOR = {
2035
2035
  return rangeOfSeries([upper, lower], startIndex, endIndex, skipIndex);
2036
2036
  }
2037
2037
  };
2038
+ var computeVolumeProfile = (data, fromIndex, toIndex, rowCount, valueAreaPercent) => {
2039
+ const from = Math.max(0, fromIndex);
2040
+ const to = Math.min(data.length - 1, toIndex);
2041
+ if (to < from) return null;
2042
+ let low = Number.POSITIVE_INFINITY;
2043
+ let high = Number.NEGATIVE_INFINITY;
2044
+ for (let i = from; i <= to; i += 1) {
2045
+ const bar = data[i];
2046
+ if (!bar) continue;
2047
+ if (bar.l < low) low = bar.l;
2048
+ if (bar.h > high) high = bar.h;
2049
+ }
2050
+ if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
2051
+ if (high - low < Number.EPSILON) {
2052
+ const pad = Math.max(Math.abs(high) * 1e-6, 1e-6);
2053
+ low -= pad;
2054
+ high += pad;
2055
+ }
2056
+ const rows = Math.max(4, Math.min(500, Math.floor(rowCount) || 24));
2057
+ const rowSize = (high - low) / rows;
2058
+ const buckets = Array.from({ length: rows }, (_, index) => ({
2059
+ low: low + index * rowSize,
2060
+ high: low + (index + 1) * rowSize,
2061
+ up: 0,
2062
+ down: 0,
2063
+ total: 0
2064
+ }));
2065
+ let totalVolume = 0;
2066
+ for (let i = from; i <= to; i += 1) {
2067
+ const bar = data[i];
2068
+ if (!bar) continue;
2069
+ const volume = Math.max(0, bar.v ?? 0);
2070
+ if (volume <= 0) continue;
2071
+ const firstRow = Math.max(0, Math.min(rows - 1, Math.floor((bar.l - low) / rowSize)));
2072
+ const lastRow = Math.max(0, Math.min(rows - 1, Math.floor((bar.h - low) / rowSize)));
2073
+ const share = volume / (lastRow - firstRow + 1);
2074
+ const up = bar.c >= bar.o;
2075
+ for (let row = firstRow; row <= lastRow; row += 1) {
2076
+ const bucket = buckets[row];
2077
+ if (up) bucket.up += share;
2078
+ else bucket.down += share;
2079
+ bucket.total += share;
2080
+ }
2081
+ totalVolume += volume;
2082
+ }
2083
+ if (totalVolume <= 0) return null;
2084
+ let pocIndex = 0;
2085
+ let maxRowVolume = 0;
2086
+ for (let row = 0; row < rows; row += 1) {
2087
+ const total = buckets[row].total;
2088
+ if (total > maxRowVolume) {
2089
+ maxRowVolume = total;
2090
+ pocIndex = row;
2091
+ }
2092
+ }
2093
+ const target = totalVolume * Math.min(0.99, Math.max(0.1, valueAreaPercent / 100));
2094
+ let valueAreaFrom = pocIndex;
2095
+ let valueAreaTo = pocIndex;
2096
+ let accumulated = buckets[pocIndex].total;
2097
+ while (accumulated < target && (valueAreaFrom > 0 || valueAreaTo < rows - 1)) {
2098
+ const above = valueAreaTo < rows - 1 ? buckets[valueAreaTo + 1].total : -1;
2099
+ const below = valueAreaFrom > 0 ? buckets[valueAreaFrom - 1].total : -1;
2100
+ if (above < 0 && below < 0) break;
2101
+ if (above >= below) {
2102
+ valueAreaTo += 1;
2103
+ accumulated += above;
2104
+ } else {
2105
+ valueAreaFrom -= 1;
2106
+ accumulated += below;
2107
+ }
2108
+ }
2109
+ return { rows: buckets, rowSize, low, high, maxRowVolume, totalVolume, pocIndex, valueAreaFrom, valueAreaTo };
2110
+ };
2111
+ var sessionSegments = (renderContext, fromIndex, toIndex) => {
2112
+ const session = renderContext.session;
2113
+ if (!session) return [{ from: fromIndex, to: toIndex }];
2114
+ const segments = [];
2115
+ let start = fromIndex;
2116
+ let day = session.dayAt(fromIndex);
2117
+ for (let index = fromIndex + 1; index <= toIndex; index += 1) {
2118
+ const current = session.dayAt(index);
2119
+ if (current !== day) {
2120
+ segments.push({ from: start, to: index - 1 });
2121
+ start = index;
2122
+ day = current;
2123
+ }
2124
+ }
2125
+ segments.push({ from: start, to: toIndex });
2126
+ return segments;
2127
+ };
2128
+ var withAlpha = (color, alpha) => {
2129
+ const clamped = Math.max(0, Math.min(1, alpha));
2130
+ if (color.startsWith("#") && (color.length === 7 || color.length === 4)) {
2131
+ const hex = color.length === 4 ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` : color;
2132
+ const r = Number.parseInt(hex.slice(1, 3), 16);
2133
+ const g = Number.parseInt(hex.slice(3, 5), 16);
2134
+ const b = Number.parseInt(hex.slice(5, 7), 16);
2135
+ return `rgba(${r},${g},${b},${clamped})`;
2136
+ }
2137
+ if (color.startsWith("rgba(")) return color;
2138
+ if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(")", `,${clamped})`);
2139
+ return color;
2140
+ };
2141
+ var BUILTIN_VOLUME_PROFILE_INDICATOR = {
2142
+ id: "volume-profile",
2143
+ name: "Volume Profile",
2144
+ pane: "overlay",
2145
+ defaultInputs: {
2146
+ // "visible" profiles everything on screen and follows pan/zoom; "session"
2147
+ // draws one profile per trading day inside that day's bars.
2148
+ scope: "visible",
2149
+ rows: 40,
2150
+ widthPercent: 28,
2151
+ placement: "right",
2152
+ valueAreaPercent: 70,
2153
+ upColor: "#26a69a",
2154
+ downColor: "#ef5350",
2155
+ valueAreaOpacity: 0.55,
2156
+ outsideOpacity: 0.22,
2157
+ showPoc: true,
2158
+ pocColor: "#f7a600",
2159
+ showValueArea: true,
2160
+ valueAreaColor: "#7e8aa3",
2161
+ showLabels: true
2162
+ },
2163
+ draw: (ctx, renderContext, inputs) => {
2164
+ const { yFromPrice, chartLeft, chartRight, chartTop, chartBottom, data } = renderContext;
2165
+ if (!yFromPrice || data.length === 0) return;
2166
+ const from = Math.max(0, renderContext.startIndex);
2167
+ const to = Math.min(data.length - 1, renderContext.endIndex);
2168
+ if (to < from) return;
2169
+ const rows = Math.max(4, Math.min(500, Math.floor(Number(inputs.rows)) || 40));
2170
+ const valueAreaPercent = Math.max(10, Math.min(99, Number(inputs.valueAreaPercent) || 70));
2171
+ const perSession = inputs.scope === "session" && renderContext.session !== void 0;
2172
+ const segments = perSession ? sessionSegments(renderContext, from, to) : [{ from, to }];
2173
+ const widthRatio = Math.max(0.02, Math.min(0.9, (Number(inputs.widthPercent) || 28) / 100));
2174
+ const placeLeft = inputs.placement === "left";
2175
+ ctx.save();
2176
+ ctx.beginPath();
2177
+ ctx.rect(chartLeft, chartTop, chartRight - chartLeft, chartBottom - chartTop);
2178
+ ctx.clip();
2179
+ for (const segment of segments) {
2180
+ const profile = withCachedComputation(
2181
+ `volume-profile|${perSession ? "s" : "v"}|${segment.from}|${segment.to}|${rows}|${valueAreaPercent}`,
2182
+ data,
2183
+ () => computeVolumeProfile(data, segment.from, segment.to, rows, valueAreaPercent)
2184
+ );
2185
+ if (!profile || profile.maxRowVolume <= 0) continue;
2186
+ const segmentLeft = renderContext.xFromIndex(segment.from) - renderContext.candleSpacing / 2;
2187
+ const segmentRight = renderContext.xFromIndex(segment.to) + renderContext.candleSpacing / 2;
2188
+ const available = perSession ? Math.max(0, Math.min(segmentRight, chartRight) - Math.max(segmentLeft, chartLeft)) : chartRight - chartLeft;
2189
+ const maxWidth = available * (perSession ? Math.min(1, widthRatio * 2.2) : widthRatio);
2190
+ if (maxWidth < (perSession ? 10 : 2)) continue;
2191
+ const anchorX = perSession ? Math.max(segmentLeft, chartLeft) : placeLeft ? chartLeft : chartRight;
2192
+ const direction = perSession ? 1 : placeLeft ? 1 : -1;
2193
+ for (let row = 0; row < profile.rows.length; row += 1) {
2194
+ const bucket = profile.rows[row];
2195
+ if (bucket.total <= 0) continue;
2196
+ const yTop = yFromPrice(bucket.high);
2197
+ const yBottom = yFromPrice(bucket.low);
2198
+ const height = Math.max(1, Math.abs(yBottom - yTop) - 1);
2199
+ const top = Math.min(yTop, yBottom);
2200
+ if (top > chartBottom || top + height < chartTop) continue;
2201
+ const inValueArea = row >= profile.valueAreaFrom && row <= profile.valueAreaTo;
2202
+ const alpha = inValueArea ? Math.max(0, Math.min(1, Number(inputs.valueAreaOpacity) || 0.55)) : Math.max(0, Math.min(1, Number(inputs.outsideOpacity) || 0.22));
2203
+ const rowWidth = bucket.total / profile.maxRowVolume * maxWidth;
2204
+ const upWidth = bucket.total > 0 ? rowWidth * (bucket.up / bucket.total) : 0;
2205
+ ctx.fillStyle = withAlpha(inputs.upColor ?? "#26a69a", alpha);
2206
+ ctx.fillRect(direction > 0 ? anchorX : anchorX - upWidth, top, upWidth, height);
2207
+ ctx.fillStyle = withAlpha(inputs.downColor ?? "#ef5350", alpha);
2208
+ const downWidth = rowWidth - upWidth;
2209
+ ctx.fillRect(
2210
+ direction > 0 ? anchorX + upWidth : anchorX - rowWidth,
2211
+ top,
2212
+ downWidth,
2213
+ height
2214
+ );
2215
+ }
2216
+ const lineFrom = direction > 0 ? anchorX : anchorX - maxWidth;
2217
+ const lineTo = direction > 0 ? anchorX + maxWidth : anchorX;
2218
+ const drawLevel = (price, color, label, dashed) => {
2219
+ const y = yFromPrice(price);
2220
+ if (y < chartTop || y > chartBottom) return;
2221
+ ctx.save();
2222
+ ctx.strokeStyle = color;
2223
+ ctx.lineWidth = 1;
2224
+ ctx.setLineDash(dashed ? [4, 3] : []);
2225
+ ctx.beginPath();
2226
+ ctx.moveTo(lineFrom, Math.round(y) + 0.5);
2227
+ ctx.lineTo(lineTo, Math.round(y) + 0.5);
2228
+ ctx.stroke();
2229
+ ctx.restore();
2230
+ if (inputs.showLabels && !perSession) {
2231
+ ctx.save();
2232
+ ctx.fillStyle = color;
2233
+ ctx.font = "10px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
2234
+ ctx.textAlign = direction > 0 ? "left" : "right";
2235
+ ctx.textBaseline = "bottom";
2236
+ ctx.fillText(label, direction > 0 ? lineFrom + 3 : lineTo - 3, y - 1);
2237
+ ctx.restore();
2238
+ }
2239
+ };
2240
+ const poc = profile.rows[profile.pocIndex];
2241
+ if (inputs.showValueArea) {
2242
+ const areaHigh = profile.rows[profile.valueAreaTo];
2243
+ const areaLow = profile.rows[profile.valueAreaFrom];
2244
+ const areaColor = inputs.valueAreaColor ?? "#7e8aa3";
2245
+ drawLevel(areaHigh.high, areaColor, "VAH", true);
2246
+ drawLevel(areaLow.low, areaColor, "VAL", true);
2247
+ }
2248
+ if (inputs.showPoc) {
2249
+ drawLevel((poc.low + poc.high) / 2, inputs.pocColor ?? "#f7a600", "POC", false);
2250
+ }
2251
+ }
2252
+ ctx.restore();
2253
+ }
2254
+ };
2255
+ var parseMinutes = (value, fallback) => {
2256
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2257
+ const match = /^(\d{1,2}):?(\d{2})$/.exec(String(value ?? "").trim());
2258
+ if (!match) return fallback;
2259
+ const hour = Number(match[1]);
2260
+ const minute = Number(match[2]);
2261
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return fallback;
2262
+ return hour * 60 + minute;
2263
+ };
2264
+ var computeSessionLevels = (data, session, rthStart, rthEnd, orMinutes) => {
2265
+ const lastIndex = data.length - 1;
2266
+ if (lastIndex < 0) return [];
2267
+ const currentDay = session.dayAt(lastIndex);
2268
+ if (!Number.isFinite(currentDay)) return [];
2269
+ let currentFrom = lastIndex;
2270
+ while (currentFrom > 0 && session.dayAt(currentFrom - 1) === currentDay) currentFrom -= 1;
2271
+ const priorTo = currentFrom - 1;
2272
+ const result = [];
2273
+ if (priorTo >= 0) {
2274
+ const priorDay = session.dayAt(priorTo);
2275
+ let priorFrom = priorTo;
2276
+ while (priorFrom > 0 && session.dayAt(priorFrom - 1) === priorDay) priorFrom -= 1;
2277
+ let high = Number.NEGATIVE_INFINITY;
2278
+ let low = Number.POSITIVE_INFINITY;
2279
+ for (let i = priorFrom; i <= priorTo; i += 1) {
2280
+ const bar = data[i];
2281
+ if (!bar) continue;
2282
+ if (bar.h > high) high = bar.h;
2283
+ if (bar.l < low) low = bar.l;
2284
+ }
2285
+ const close = data[priorTo]?.c;
2286
+ if (Number.isFinite(high)) result.push({ price: high, label: "PDH", color: "prior", fromIndex: currentFrom });
2287
+ if (Number.isFinite(low)) result.push({ price: low, label: "PDL", color: "prior", fromIndex: currentFrom });
2288
+ if (close !== void 0) result.push({ price: close, label: "PDC", color: "prior", fromIndex: currentFrom });
2289
+ }
2290
+ let onHigh = Number.NEGATIVE_INFINITY;
2291
+ let onLow = Number.POSITIVE_INFINITY;
2292
+ let orHigh = Number.NEGATIVE_INFINITY;
2293
+ let orLow = Number.POSITIVE_INFINITY;
2294
+ let ibHigh = Number.NEGATIVE_INFINITY;
2295
+ let ibLow = Number.POSITIVE_INFINITY;
2296
+ let orFromIndex = lastIndex;
2297
+ let ibFromIndex = lastIndex;
2298
+ for (let i = currentFrom; i <= lastIndex; i += 1) {
2299
+ const bar = data[i];
2300
+ if (!bar) continue;
2301
+ const minutes = session.minutesOfDayAt(i);
2302
+ if (!Number.isFinite(minutes)) continue;
2303
+ if (minutes < rthStart || minutes >= rthEnd) {
2304
+ if (bar.h > onHigh) onHigh = bar.h;
2305
+ if (bar.l < onLow) onLow = bar.l;
2306
+ continue;
2307
+ }
2308
+ if (minutes < rthStart + orMinutes) {
2309
+ if (bar.h > orHigh) orHigh = bar.h;
2310
+ if (bar.l < orLow) orLow = bar.l;
2311
+ orFromIndex = Math.min(orFromIndex, i);
2312
+ }
2313
+ if (minutes < rthStart + 60) {
2314
+ if (bar.h > ibHigh) ibHigh = bar.h;
2315
+ if (bar.l < ibLow) ibLow = bar.l;
2316
+ ibFromIndex = Math.min(ibFromIndex, i);
2317
+ }
2318
+ }
2319
+ if (Number.isFinite(onHigh)) result.push({ price: onHigh, label: "ONH", color: "overnight", fromIndex: currentFrom });
2320
+ if (Number.isFinite(onLow)) result.push({ price: onLow, label: "ONL", color: "overnight", fromIndex: currentFrom });
2321
+ if (Number.isFinite(orHigh)) result.push({ price: orHigh, label: "ORH", color: "openingRange", fromIndex: orFromIndex });
2322
+ if (Number.isFinite(orLow)) result.push({ price: orLow, label: "ORL", color: "openingRange", fromIndex: orFromIndex });
2323
+ if (Number.isFinite(ibHigh)) result.push({ price: ibHigh, label: "IBH", color: "initialBalance", fromIndex: ibFromIndex });
2324
+ if (Number.isFinite(ibLow)) result.push({ price: ibLow, label: "IBL", color: "initialBalance", fromIndex: ibFromIndex });
2325
+ return result;
2326
+ };
2327
+ var BUILTIN_SESSION_LEVELS_INDICATOR = {
2328
+ id: "session-levels",
2329
+ name: "Session levels",
2330
+ pane: "overlay",
2331
+ defaultInputs: {
2332
+ showPriorDay: true,
2333
+ showOvernight: true,
2334
+ showOpeningRange: true,
2335
+ showInitialBalance: false,
2336
+ // Cash-session bounds in the session's own zone; everything before the
2337
+ // open on the same trading day counts as overnight.
2338
+ rthStart: "09:30",
2339
+ rthEnd: "16:00",
2340
+ openingRangeMinutes: 30,
2341
+ priorDayColor: "#8b93a7",
2342
+ overnightColor: "#7c5cff",
2343
+ openingRangeColor: "#26a69a",
2344
+ initialBalanceColor: "#f7a600",
2345
+ lineWidth: 1,
2346
+ showLabels: true
2347
+ },
2348
+ draw: (ctx, renderContext, inputs) => {
2349
+ const { yFromPrice, chartLeft, chartRight, chartTop, chartBottom, data, session } = renderContext;
2350
+ if (!yFromPrice || !session || data.length === 0) return;
2351
+ const rthStart = parseMinutes(inputs.rthStart, 570);
2352
+ const rthEnd = parseMinutes(inputs.rthEnd, 960);
2353
+ const orMinutes = Math.max(1, Math.floor(Number(inputs.openingRangeMinutes) || 30));
2354
+ const levels = withCachedComputation(
2355
+ `session-levels|${rthStart}|${rthEnd}|${orMinutes}|${data.length}`,
2356
+ data,
2357
+ () => computeSessionLevels(data, session, rthStart, rthEnd, orMinutes)
2358
+ );
2359
+ const groupEnabled = {
2360
+ prior: inputs.showPriorDay !== false,
2361
+ overnight: inputs.showOvernight !== false,
2362
+ openingRange: inputs.showOpeningRange !== false,
2363
+ initialBalance: inputs.showInitialBalance === true
2364
+ };
2365
+ const groupColor = {
2366
+ prior: inputs.priorDayColor ?? "#8b93a7",
2367
+ overnight: inputs.overnightColor ?? "#7c5cff",
2368
+ openingRange: inputs.openingRangeColor ?? "#26a69a",
2369
+ initialBalance: inputs.initialBalanceColor ?? "#f7a600"
2370
+ };
2371
+ ctx.save();
2372
+ ctx.lineWidth = Math.max(1, Number(inputs.lineWidth) || 1);
2373
+ ctx.font = "10px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
2374
+ ctx.textAlign = "right";
2375
+ ctx.textBaseline = "bottom";
2376
+ for (const level of levels) {
2377
+ if (!groupEnabled[level.color]) continue;
2378
+ const y = yFromPrice(level.price);
2379
+ if (!Number.isFinite(y) || y < chartTop || y > chartBottom) continue;
2380
+ const startX = Math.max(chartLeft, renderContext.xFromIndex(level.fromIndex));
2381
+ const color = groupColor[level.color] ?? "#8b93a7";
2382
+ ctx.strokeStyle = color;
2383
+ ctx.setLineDash(level.color === "prior" ? [] : [5, 4]);
2384
+ ctx.beginPath();
2385
+ ctx.moveTo(startX, Math.round(y) + 0.5);
2386
+ ctx.lineTo(chartRight, Math.round(y) + 0.5);
2387
+ ctx.stroke();
2388
+ if (inputs.showLabels !== false) {
2389
+ ctx.fillStyle = color;
2390
+ ctx.fillText(level.label, chartRight - 4, y - 2);
2391
+ }
2392
+ }
2393
+ ctx.restore();
2394
+ }
2395
+ };
2396
+ var BUILTIN_ANCHORED_VWAP_INDICATOR = {
2397
+ id: "anchored-vwap",
2398
+ name: "Anchored VWAP",
2399
+ pane: "overlay",
2400
+ defaultInputs: {
2401
+ anchorTime: "",
2402
+ color: "#22d3ee",
2403
+ width: 2,
2404
+ showBands: false,
2405
+ bandMultiplier: 1,
2406
+ bandColor: "#94a3b8",
2407
+ bandFillOpacity: 0.06,
2408
+ showAnchorMarker: true
2409
+ },
2410
+ draw: (ctx, renderContext, inputs) => {
2411
+ const { data, yFromPrice } = renderContext;
2412
+ if (!yFromPrice || data.length === 0) return;
2413
+ const anchorIndex = resolveAnchorIndex(renderContext, inputs.anchorTime);
2414
+ if (anchorIndex === null) return;
2415
+ const series = withCachedComputation(
2416
+ `anchored-vwap|${anchorIndex}|${inputs.showBands ? "b" : "n"}`,
2417
+ data,
2418
+ () => computeAnchoredVwap(data, anchorIndex)
2419
+ );
2420
+ if (inputs.showBands) {
2421
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
2422
+ const upper = series.vwap.map((value, idx) => {
2423
+ const sigma = series.sigma[idx];
2424
+ return value == null || sigma == null ? null : value + multiplier * sigma;
2425
+ });
2426
+ const lower = series.vwap.map((value, idx) => {
2427
+ const sigma = series.sigma[idx];
2428
+ return value == null || sigma == null ? null : value - multiplier * sigma;
2429
+ });
2430
+ const bandColor = inputs.bandColor ?? "#94a3b8";
2431
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.bandFillOpacity) || 0.06);
2432
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, 1);
2433
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
2434
+ }
2435
+ drawOverlaySeries(ctx, renderContext, series.vwap, inputs.color ?? "#22d3ee", Number(inputs.width) || 2);
2436
+ if (inputs.showAnchorMarker !== false) {
2437
+ const anchorBar = data[anchorIndex];
2438
+ const anchorValue = series.vwap[anchorIndex];
2439
+ if (anchorBar && anchorValue != null) {
2440
+ const x = renderContext.xFromIndex(anchorIndex);
2441
+ const y = yFromPrice(anchorValue);
2442
+ if (x >= renderContext.chartLeft - 8 && x <= renderContext.chartRight + 8) {
2443
+ ctx.save();
2444
+ ctx.fillStyle = inputs.color ?? "#22d3ee";
2445
+ ctx.beginPath();
2446
+ ctx.arc(x, y, 3.5, 0, Math.PI * 2);
2447
+ ctx.fill();
2448
+ ctx.restore();
2449
+ }
2450
+ }
2451
+ }
2452
+ },
2453
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
2454
+ const anchorIndex = resolveAnchorIndexFromData(data, inputs.anchorTime);
2455
+ if (anchorIndex === null) return null;
2456
+ const series = withCachedComputation(
2457
+ `anchored-vwap|${anchorIndex}|${inputs.showBands ? "b" : "n"}`,
2458
+ data,
2459
+ () => computeAnchoredVwap(data, anchorIndex)
2460
+ );
2461
+ if (!inputs.showBands) return rangeOfSeries([series.vwap], startIndex, endIndex, skipIndex);
2462
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
2463
+ let min = Number.POSITIVE_INFINITY;
2464
+ let max = Number.NEGATIVE_INFINITY;
2465
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
2466
+ if (idx === skipIndex) continue;
2467
+ const value = series.vwap[idx];
2468
+ const sigma = series.sigma[idx];
2469
+ if (value == null || sigma == null) continue;
2470
+ const lo = value - multiplier * sigma;
2471
+ const hi = value + multiplier * sigma;
2472
+ if (lo < min) min = lo;
2473
+ if (hi > max) max = hi;
2474
+ }
2475
+ return min <= max ? { min, max } : null;
2476
+ }
2477
+ };
2478
+ var resolveAnchorIndexFromData = (data, anchorTime) => {
2479
+ if (data.length === 0) return null;
2480
+ const raw = typeof anchorTime === "number" ? anchorTime : Date.parse(String(anchorTime ?? ""));
2481
+ if (!Number.isFinite(raw)) return null;
2482
+ let lo = 0;
2483
+ let hi = data.length - 1;
2484
+ if (raw <= data[0].time.getTime()) return 0;
2485
+ if (raw >= data[hi].time.getTime()) return hi;
2486
+ while (lo < hi) {
2487
+ const mid = lo + hi + 1 >> 1;
2488
+ if (data[mid].time.getTime() <= raw) lo = mid;
2489
+ else hi = mid - 1;
2490
+ }
2491
+ return lo;
2492
+ };
2493
+ var resolveAnchorIndex = (renderContext, anchorTime) => {
2494
+ const explicit = resolveAnchorIndexFromData(renderContext.data, anchorTime);
2495
+ if (explicit !== null) return explicit;
2496
+ const session = renderContext.session;
2497
+ const lastIndex = renderContext.data.length - 1;
2498
+ if (!session || lastIndex < 0) return lastIndex >= 0 ? 0 : null;
2499
+ const day = session.dayAt(lastIndex);
2500
+ let index = lastIndex;
2501
+ while (index > 0 && session.dayAt(index - 1) === day) index -= 1;
2502
+ return index;
2503
+ };
2504
+ var computeAnchoredVwap = (data, anchorIndex) => {
2505
+ const vwap = new Array(data.length).fill(null);
2506
+ const sigma = new Array(data.length).fill(null);
2507
+ let cumPv = 0;
2508
+ let cumPv2 = 0;
2509
+ let cumV = 0;
2510
+ for (let i = Math.max(0, anchorIndex); i < data.length; i += 1) {
2511
+ const bar = data[i];
2512
+ if (!bar) continue;
2513
+ const typical = (bar.h + bar.l + bar.c) / 3;
2514
+ const volume = Math.max(0, bar.v ?? 0) || 1;
2515
+ cumPv += typical * volume;
2516
+ cumPv2 += typical * typical * volume;
2517
+ cumV += volume;
2518
+ if (cumV <= 0) continue;
2519
+ const value = cumPv / cumV;
2520
+ vwap[i] = value;
2521
+ sigma[i] = Math.sqrt(Math.max(0, cumPv2 / cumV - value * value));
2522
+ }
2523
+ return { vwap, sigma };
2524
+ };
2038
2525
  var BUILTIN_INDICATORS = [
2039
2526
  BUILTIN_VOLUME_INDICATOR,
2040
2527
  BUILTIN_SMA_INDICATOR,
@@ -2062,7 +2549,10 @@ var BUILTIN_INDICATORS = [
2062
2549
  BUILTIN_SUPERTREND_INDICATOR,
2063
2550
  BUILTIN_ICHIMOKU_INDICATOR,
2064
2551
  BUILTIN_KELTNER_INDICATOR,
2065
- BUILTIN_DONCHIAN_INDICATOR
2552
+ BUILTIN_DONCHIAN_INDICATOR,
2553
+ BUILTIN_VOLUME_PROFILE_INDICATOR,
2554
+ BUILTIN_SESSION_LEVELS_INDICATOR,
2555
+ BUILTIN_ANCHORED_VWAP_INDICATOR
2066
2556
  ];
2067
2557
 
2068
2558
  // src/scripts.ts
@@ -3458,6 +3948,44 @@ function createChart(element, options = {}) {
3458
3948
  let compiledSession = compileSession(sessionOptions.spec);
3459
3949
  const isHour12 = () => timeFormat === "12h";
3460
3950
  const formatClock = (time) => zoneClock.formatTime(time.getTime(), isHour12());
3951
+ let sessionInfoCache = [];
3952
+ let sessionInfoCacheKey = "";
3953
+ const sessionInfoForIndex = (index) => {
3954
+ if (!compiledSession) return null;
3955
+ const point = data[index];
3956
+ if (!point) return null;
3957
+ const key = `${data.length}|${data[0]?.time.getTime() ?? 0}|${data[data.length - 1]?.time.getTime() ?? 0}|${sessionOptions.spec === null ? "none" : JSON.stringify(sessionOptions.spec)}`;
3958
+ if (key !== sessionInfoCacheKey) {
3959
+ sessionInfoCacheKey = key;
3960
+ sessionInfoCache = new Array(data.length);
3961
+ }
3962
+ const cached = sessionInfoCache[index];
3963
+ if (cached) return cached;
3964
+ const ms = point.time.getTime();
3965
+ const info = sessionInfoAt(compiledSession, ms);
3966
+ const resolved = {
3967
+ day: info.sessionDay,
3968
+ inSession: info.inSession,
3969
+ minutes: compiledSession.clock.minutesOfDay(ms)
3970
+ };
3971
+ sessionInfoCache[index] = resolved;
3972
+ return resolved;
3973
+ };
3974
+ const buildIndicatorSessionContext = () => {
3975
+ if (!compiledSession) return void 0;
3976
+ const dayAt = (index) => sessionInfoForIndex(index)?.day ?? Number.NaN;
3977
+ return {
3978
+ dayAt,
3979
+ inSessionAt: (index) => sessionInfoForIndex(index)?.inSession ?? false,
3980
+ isSessionStart: (index) => {
3981
+ if (index <= 0) return index === 0;
3982
+ const current = dayAt(index);
3983
+ const previous = dayAt(index - 1);
3984
+ return Number.isFinite(current) && Number.isFinite(previous) && current !== previous;
3985
+ },
3986
+ minutesOfDayAt: (index) => sessionInfoForIndex(index)?.minutes ?? Number.NaN
3987
+ };
3988
+ };
3461
3989
  let barMarks = [];
3462
3990
  let timescaleMarks = [];
3463
3991
  let markRegions = [];
@@ -6864,6 +7392,7 @@ function createChart(element, options = {}) {
6864
7392
  ).sort((a, b) => a.indicator.zIndex - b.indicator.zIndex);
6865
7393
  if (activeOverlayIndicators.length > 0) {
6866
7394
  const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
7395
+ const indicatorSession = buildIndicatorSessionContext();
6867
7396
  activeOverlayIndicators.forEach(({ indicator, plugin }) => {
6868
7397
  plugin.draw(
6869
7398
  ctx,
@@ -6885,7 +7414,8 @@ function createChart(element, options = {}) {
6885
7414
  getVolumeByIndex,
6886
7415
  candleSpacing,
6887
7416
  upColor: mergedOptions.upColor,
6888
- downColor: mergedOptions.downColor
7417
+ downColor: mergedOptions.downColor,
7418
+ ...indicatorSession ? { session: indicatorSession } : {}
6889
7419
  },
6890
7420
  indicator.inputs
6891
7421
  );
@@ -7685,8 +8215,17 @@ function createChart(element, options = {}) {
7685
8215
  return typeof value === "string" && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value.trim());
7686
8216
  };
7687
8217
  const LEGEND_EXCLUDED_INPUT_KEYS = /* @__PURE__ */ new Set(["width", "bandMultiplier", "bandFillOpacity", "fillOpacity"]);
8218
+ const formatLegendValue = (value) => {
8219
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
8220
+ const ms = Date.parse(value);
8221
+ if (Number.isFinite(ms)) {
8222
+ return getTimeStepMs() < 864e5 ? `${zoneClock.formatDayMonth(ms)} ${zoneClock.formatTime(ms, isHour12())}` : zoneClock.formatDayMonth(ms);
8223
+ }
8224
+ }
8225
+ return String(value);
8226
+ };
7688
8227
  const labelEntries = activeOverlayIndicators.map(({ indicator, plugin }) => {
7689
- 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));
8228
+ 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]) => formatLegendValue(value));
7690
8229
  if (labels.showIndicatorNames && labels.showIndicatorValues && inputValues.length > 0) {
7691
8230
  return `${plugin.name} ${inputValues.join(" ")}`;
7692
8231
  }
package/docs/API.md CHANGED
@@ -625,6 +625,7 @@ link.click();
625
625
 
626
626
  - `startReplay(options?: ReplayStartOptions): void` / `stopReplay(): void`
627
627
  - `replayStep(bars?: number): void` — negative rewinds.
628
+ - `replaySeek(index: number): void` — absolute jump, clamped to the series.
628
629
  - `replayPlay(speed?: number): void` / `replayPause(): void` / `setReplaySpeed(speed: number): void`
629
630
  - `getReplayState(): ReplayState` / `onReplayStateChange(handler)`
630
631
 
@@ -724,6 +725,7 @@ chart.startReplay({ fromTimeMs: Date.parse("2026-07-14T13:30:00Z"), speed: 4 });
724
725
  chart.replayPlay(); // 4 bars per second
725
726
  chart.replayStep(1); // or step by hand
726
727
  chart.replayStep(-1); // rewind
728
+ chart.replaySeek(1200); // jump straight to a bar (scrubbers, "replay from here")
727
729
  chart.onReplayStateChange((s) => {
728
730
  label.textContent = `${s.index + 1}/${s.total}${s.atEnd ? " (end)" : ""}`;
729
731
  });
@@ -734,6 +736,16 @@ Live bars arriving during replay are buffered rather than shown, and are all
734
736
  present when replay stops. Without `fromIndex`/`fromTimeMs` replay starts 70%
735
737
  of the way through the series.
736
738
 
739
+ While replay is active the chart also takes the arrow keys and Space: arrows
740
+ step the tape (Shift steps 10) instead of panning, and Space toggles playback.
741
+ Both revert to normal as soon as replay stops, and `keyboard.replay: false`
742
+ turns them off. Stepping one bar at a time is the wrong tool for covering
743
+ distance, so pair them with `replaySeek` for a scrubber:
744
+
745
+ ```ts
746
+ scrubber.oninput = () => chart.replaySeek(Number(scrubber.value));
747
+ ```
748
+
737
749
  ---
738
750
 
739
751
  ## Price alerts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperprop-charting-library",
3
- "version": "0.1.145",
3
+ "version": "0.1.146",
4
4
  "description": "Lightweight TypeScript charting core",
5
5
  "type": "module",
6
6
  "main": "./dist/hyperprop-charting-library.cjs",
@@ -22,7 +22,7 @@
22
22
  "build": "tsup src/index.ts --format esm,cjs --dts --clean && cp \"./dist/index.js\" \"./dist/hyperprop-charting-library.js\" && cp \"./dist/index.cjs\" \"./dist/hyperprop-charting-library.cjs\" && cp \"./dist/index.d.ts\" \"./dist/hyperprop-charting-library.d.ts\"",
23
23
  "dev": "tsup src/index.ts --format esm,cjs --dts --watch --onSuccess \"cp ./dist/index.js ./dist/hyperprop-charting-library.js && cp ./dist/index.cjs ./dist/hyperprop-charting-library.cjs && cp ./dist/index.d.ts ./dist/hyperprop-charting-library.d.ts\"",
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
- "test": "npx tsx src/time.test.mjs"
25
+ "test": "npx tsx src/time.test.mjs && npx tsx src/indicators.test.mjs"
26
26
  },
27
27
  "devDependencies": {
28
28
  "tsup": "^8.5.0"