hyperprop-charting-library 0.1.145 → 0.1.147

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.cjs CHANGED
@@ -2071,6 +2071,493 @@ var BUILTIN_DONCHIAN_INDICATOR = {
2071
2071
  return rangeOfSeries([upper, lower], startIndex, endIndex, skipIndex);
2072
2072
  }
2073
2073
  };
2074
+ var computeVolumeProfile = (data, fromIndex, toIndex, rowCount, valueAreaPercent) => {
2075
+ const from = Math.max(0, Math.round(fromIndex));
2076
+ const to = Math.min(data.length - 1, Math.round(toIndex));
2077
+ if (to < from) return null;
2078
+ let low = Number.POSITIVE_INFINITY;
2079
+ let high = Number.NEGATIVE_INFINITY;
2080
+ for (let i = from; i <= to; i += 1) {
2081
+ const bar = data[i];
2082
+ if (!bar) continue;
2083
+ if (bar.l < low) low = bar.l;
2084
+ if (bar.h > high) high = bar.h;
2085
+ }
2086
+ if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
2087
+ if (high - low < Number.EPSILON) {
2088
+ const pad = Math.max(Math.abs(high) * 1e-6, 1e-6);
2089
+ low -= pad;
2090
+ high += pad;
2091
+ }
2092
+ const rows = Math.max(4, Math.min(500, Math.floor(rowCount) || 24));
2093
+ const rowSize = (high - low) / rows;
2094
+ const buckets = Array.from({ length: rows }, (_, index) => ({
2095
+ low: low + index * rowSize,
2096
+ high: low + (index + 1) * rowSize,
2097
+ up: 0,
2098
+ down: 0,
2099
+ total: 0
2100
+ }));
2101
+ let totalVolume = 0;
2102
+ for (let i = from; i <= to; i += 1) {
2103
+ const bar = data[i];
2104
+ if (!bar) continue;
2105
+ const volume = Math.max(0, bar.v ?? 0);
2106
+ if (volume <= 0) continue;
2107
+ const firstRow = Math.max(0, Math.min(rows - 1, Math.floor((bar.l - low) / rowSize)));
2108
+ const lastRow = Math.max(0, Math.min(rows - 1, Math.floor((bar.h - low) / rowSize)));
2109
+ const share = volume / (lastRow - firstRow + 1);
2110
+ const up = bar.c >= bar.o;
2111
+ for (let row = firstRow; row <= lastRow; row += 1) {
2112
+ const bucket = buckets[row];
2113
+ if (up) bucket.up += share;
2114
+ else bucket.down += share;
2115
+ bucket.total += share;
2116
+ }
2117
+ totalVolume += volume;
2118
+ }
2119
+ if (totalVolume <= 0) return null;
2120
+ let pocIndex = 0;
2121
+ let maxRowVolume = 0;
2122
+ for (let row = 0; row < rows; row += 1) {
2123
+ const total = buckets[row].total;
2124
+ if (total > maxRowVolume) {
2125
+ maxRowVolume = total;
2126
+ pocIndex = row;
2127
+ }
2128
+ }
2129
+ const target = totalVolume * Math.min(0.99, Math.max(0.1, valueAreaPercent / 100));
2130
+ let valueAreaFrom = pocIndex;
2131
+ let valueAreaTo = pocIndex;
2132
+ let accumulated = buckets[pocIndex].total;
2133
+ while (accumulated < target && (valueAreaFrom > 0 || valueAreaTo < rows - 1)) {
2134
+ const above = valueAreaTo < rows - 1 ? buckets[valueAreaTo + 1].total : -1;
2135
+ const below = valueAreaFrom > 0 ? buckets[valueAreaFrom - 1].total : -1;
2136
+ if (above < 0 && below < 0) break;
2137
+ if (above >= below) {
2138
+ valueAreaTo += 1;
2139
+ accumulated += above;
2140
+ } else {
2141
+ valueAreaFrom -= 1;
2142
+ accumulated += below;
2143
+ }
2144
+ }
2145
+ return { rows: buckets, rowSize, low, high, maxRowVolume, totalVolume, pocIndex, valueAreaFrom, valueAreaTo };
2146
+ };
2147
+ var sessionSegments = (renderContext, fromIndex, toIndex) => {
2148
+ const session = renderContext.session;
2149
+ if (!session) return [{ from: fromIndex, to: toIndex }];
2150
+ const segments = [];
2151
+ let start = fromIndex;
2152
+ let day = session.dayAt(fromIndex);
2153
+ for (let index = fromIndex + 1; index <= toIndex; index += 1) {
2154
+ const current = session.dayAt(index);
2155
+ if (current !== day) {
2156
+ segments.push({ from: start, to: index - 1 });
2157
+ start = index;
2158
+ day = current;
2159
+ }
2160
+ }
2161
+ segments.push({ from: start, to: toIndex });
2162
+ return segments;
2163
+ };
2164
+ var withAlpha = (color, alpha) => {
2165
+ const clamped = Math.max(0, Math.min(1, alpha));
2166
+ if (color.startsWith("#") && (color.length === 7 || color.length === 4)) {
2167
+ const hex = color.length === 4 ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` : color;
2168
+ const r = Number.parseInt(hex.slice(1, 3), 16);
2169
+ const g = Number.parseInt(hex.slice(3, 5), 16);
2170
+ const b = Number.parseInt(hex.slice(5, 7), 16);
2171
+ return `rgba(${r},${g},${b},${clamped})`;
2172
+ }
2173
+ if (color.startsWith("rgba(")) return color;
2174
+ if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(")", `,${clamped})`);
2175
+ return color;
2176
+ };
2177
+ var BUILTIN_VOLUME_PROFILE_INDICATOR = {
2178
+ id: "volume-profile",
2179
+ name: "Volume Profile",
2180
+ pane: "overlay",
2181
+ defaultInputs: {
2182
+ // "visible" profiles everything on screen and follows pan/zoom; "session"
2183
+ // draws one profile per trading day inside that day's bars.
2184
+ scope: "visible",
2185
+ rows: 40,
2186
+ widthPercent: 28,
2187
+ placement: "right",
2188
+ valueAreaPercent: 70,
2189
+ upColor: "#26a69a",
2190
+ downColor: "#ef5350",
2191
+ valueAreaOpacity: 0.55,
2192
+ outsideOpacity: 0.22,
2193
+ showPoc: true,
2194
+ pocColor: "#f7a600",
2195
+ showValueArea: true,
2196
+ valueAreaColor: "#7e8aa3",
2197
+ showLabels: true
2198
+ },
2199
+ draw: (ctx, renderContext, inputs) => {
2200
+ const { yFromPrice, chartLeft, chartRight, chartTop, chartBottom, data } = renderContext;
2201
+ if (!yFromPrice || data.length === 0) return;
2202
+ const from = Math.max(0, renderContext.startIndex);
2203
+ const to = Math.min(data.length - 1, renderContext.endIndex);
2204
+ if (to < from) return;
2205
+ const rows = Math.max(4, Math.min(500, Math.floor(Number(inputs.rows)) || 40));
2206
+ const valueAreaPercent = Math.max(10, Math.min(99, Number(inputs.valueAreaPercent) || 70));
2207
+ const perSession = inputs.scope === "session" && renderContext.session !== void 0;
2208
+ const segments = perSession ? sessionSegments(renderContext, from, to) : [{ from, to }];
2209
+ const widthRatio = Math.max(0.02, Math.min(0.9, (Number(inputs.widthPercent) || 28) / 100));
2210
+ const placeLeft = inputs.placement === "left";
2211
+ ctx.save();
2212
+ ctx.beginPath();
2213
+ ctx.rect(chartLeft, chartTop, chartRight - chartLeft, chartBottom - chartTop);
2214
+ ctx.clip();
2215
+ for (const segment of segments) {
2216
+ const profile = withCachedComputation(
2217
+ `volume-profile|${perSession ? "s" : "v"}|${segment.from}|${segment.to}|${rows}|${valueAreaPercent}`,
2218
+ data,
2219
+ () => computeVolumeProfile(data, segment.from, segment.to, rows, valueAreaPercent)
2220
+ );
2221
+ if (!profile || profile.maxRowVolume <= 0) continue;
2222
+ const segmentLeft = renderContext.xFromIndex(segment.from) - renderContext.candleSpacing / 2;
2223
+ const segmentRight = renderContext.xFromIndex(segment.to) + renderContext.candleSpacing / 2;
2224
+ const available = perSession ? Math.max(0, Math.min(segmentRight, chartRight) - Math.max(segmentLeft, chartLeft)) : chartRight - chartLeft;
2225
+ const maxWidth = available * (perSession ? Math.min(1, widthRatio * 2.2) : widthRatio);
2226
+ if (maxWidth < (perSession ? 10 : 2)) continue;
2227
+ const anchorX = perSession ? Math.max(segmentLeft, chartLeft) : placeLeft ? chartLeft : chartRight;
2228
+ const direction = perSession ? 1 : placeLeft ? 1 : -1;
2229
+ for (let row = 0; row < profile.rows.length; row += 1) {
2230
+ const bucket = profile.rows[row];
2231
+ if (bucket.total <= 0) continue;
2232
+ const yTop = yFromPrice(bucket.high);
2233
+ const yBottom = yFromPrice(bucket.low);
2234
+ const height = Math.max(1, Math.abs(yBottom - yTop) - 1);
2235
+ const top = Math.min(yTop, yBottom);
2236
+ if (top > chartBottom || top + height < chartTop) continue;
2237
+ const inValueArea = row >= profile.valueAreaFrom && row <= profile.valueAreaTo;
2238
+ 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));
2239
+ const rowWidth = bucket.total / profile.maxRowVolume * maxWidth;
2240
+ const upWidth = bucket.total > 0 ? rowWidth * (bucket.up / bucket.total) : 0;
2241
+ ctx.fillStyle = withAlpha(inputs.upColor ?? "#26a69a", alpha);
2242
+ ctx.fillRect(direction > 0 ? anchorX : anchorX - upWidth, top, upWidth, height);
2243
+ ctx.fillStyle = withAlpha(inputs.downColor ?? "#ef5350", alpha);
2244
+ const downWidth = rowWidth - upWidth;
2245
+ ctx.fillRect(
2246
+ direction > 0 ? anchorX + upWidth : anchorX - rowWidth,
2247
+ top,
2248
+ downWidth,
2249
+ height
2250
+ );
2251
+ }
2252
+ const lineFrom = direction > 0 ? anchorX : anchorX - maxWidth;
2253
+ const lineTo = direction > 0 ? anchorX + maxWidth : anchorX;
2254
+ const drawLevel = (price, color, label, dashed) => {
2255
+ const y = yFromPrice(price);
2256
+ if (y < chartTop || y > chartBottom) return;
2257
+ ctx.save();
2258
+ ctx.strokeStyle = color;
2259
+ ctx.lineWidth = 1;
2260
+ ctx.setLineDash(dashed ? [4, 3] : []);
2261
+ ctx.beginPath();
2262
+ ctx.moveTo(lineFrom, Math.round(y) + 0.5);
2263
+ ctx.lineTo(lineTo, Math.round(y) + 0.5);
2264
+ ctx.stroke();
2265
+ ctx.restore();
2266
+ if (inputs.showLabels && !perSession) {
2267
+ ctx.save();
2268
+ ctx.fillStyle = color;
2269
+ ctx.font = "10px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
2270
+ ctx.textAlign = direction > 0 ? "left" : "right";
2271
+ ctx.textBaseline = "bottom";
2272
+ ctx.fillText(label, direction > 0 ? lineFrom + 3 : lineTo - 3, y - 1);
2273
+ ctx.restore();
2274
+ }
2275
+ };
2276
+ const poc = profile.rows[profile.pocIndex];
2277
+ if (inputs.showValueArea) {
2278
+ const areaHigh = profile.rows[profile.valueAreaTo];
2279
+ const areaLow = profile.rows[profile.valueAreaFrom];
2280
+ const areaColor = inputs.valueAreaColor ?? "#7e8aa3";
2281
+ drawLevel(areaHigh.high, areaColor, "VAH", true);
2282
+ drawLevel(areaLow.low, areaColor, "VAL", true);
2283
+ }
2284
+ if (inputs.showPoc) {
2285
+ drawLevel((poc.low + poc.high) / 2, inputs.pocColor ?? "#f7a600", "POC", false);
2286
+ }
2287
+ }
2288
+ ctx.restore();
2289
+ }
2290
+ };
2291
+ var parseMinutes = (value, fallback) => {
2292
+ if (typeof value === "number" && Number.isFinite(value)) return value;
2293
+ const match = /^(\d{1,2}):?(\d{2})$/.exec(String(value ?? "").trim());
2294
+ if (!match) return fallback;
2295
+ const hour = Number(match[1]);
2296
+ const minute = Number(match[2]);
2297
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return fallback;
2298
+ return hour * 60 + minute;
2299
+ };
2300
+ var computeSessionLevels = (data, session, rthStart, rthEnd, orMinutes) => {
2301
+ const lastIndex = data.length - 1;
2302
+ if (lastIndex < 0) return [];
2303
+ const currentDay = session.dayAt(lastIndex);
2304
+ if (!Number.isFinite(currentDay)) return [];
2305
+ let currentFrom = lastIndex;
2306
+ while (currentFrom > 0 && session.dayAt(currentFrom - 1) === currentDay) currentFrom -= 1;
2307
+ const priorTo = currentFrom - 1;
2308
+ const result = [];
2309
+ if (priorTo >= 0) {
2310
+ const priorDay = session.dayAt(priorTo);
2311
+ let priorFrom = priorTo;
2312
+ while (priorFrom > 0 && session.dayAt(priorFrom - 1) === priorDay) priorFrom -= 1;
2313
+ let high = Number.NEGATIVE_INFINITY;
2314
+ let low = Number.POSITIVE_INFINITY;
2315
+ for (let i = priorFrom; i <= priorTo; i += 1) {
2316
+ const bar = data[i];
2317
+ if (!bar) continue;
2318
+ if (bar.h > high) high = bar.h;
2319
+ if (bar.l < low) low = bar.l;
2320
+ }
2321
+ const close = data[priorTo]?.c;
2322
+ if (Number.isFinite(high)) result.push({ price: high, label: "PDH", color: "prior", fromIndex: currentFrom });
2323
+ if (Number.isFinite(low)) result.push({ price: low, label: "PDL", color: "prior", fromIndex: currentFrom });
2324
+ if (close !== void 0) result.push({ price: close, label: "PDC", color: "prior", fromIndex: currentFrom });
2325
+ }
2326
+ let onHigh = Number.NEGATIVE_INFINITY;
2327
+ let onLow = Number.POSITIVE_INFINITY;
2328
+ let orHigh = Number.NEGATIVE_INFINITY;
2329
+ let orLow = Number.POSITIVE_INFINITY;
2330
+ let ibHigh = Number.NEGATIVE_INFINITY;
2331
+ let ibLow = Number.POSITIVE_INFINITY;
2332
+ let orFromIndex = lastIndex;
2333
+ let ibFromIndex = lastIndex;
2334
+ for (let i = currentFrom; i <= lastIndex; i += 1) {
2335
+ const bar = data[i];
2336
+ if (!bar) continue;
2337
+ const minutes = session.minutesOfDayAt(i);
2338
+ if (!Number.isFinite(minutes)) continue;
2339
+ if (minutes < rthStart || minutes >= rthEnd) {
2340
+ if (bar.h > onHigh) onHigh = bar.h;
2341
+ if (bar.l < onLow) onLow = bar.l;
2342
+ continue;
2343
+ }
2344
+ if (minutes < rthStart + orMinutes) {
2345
+ if (bar.h > orHigh) orHigh = bar.h;
2346
+ if (bar.l < orLow) orLow = bar.l;
2347
+ orFromIndex = Math.min(orFromIndex, i);
2348
+ }
2349
+ if (minutes < rthStart + 60) {
2350
+ if (bar.h > ibHigh) ibHigh = bar.h;
2351
+ if (bar.l < ibLow) ibLow = bar.l;
2352
+ ibFromIndex = Math.min(ibFromIndex, i);
2353
+ }
2354
+ }
2355
+ if (Number.isFinite(onHigh)) result.push({ price: onHigh, label: "ONH", color: "overnight", fromIndex: currentFrom });
2356
+ if (Number.isFinite(onLow)) result.push({ price: onLow, label: "ONL", color: "overnight", fromIndex: currentFrom });
2357
+ if (Number.isFinite(orHigh)) result.push({ price: orHigh, label: "ORH", color: "openingRange", fromIndex: orFromIndex });
2358
+ if (Number.isFinite(orLow)) result.push({ price: orLow, label: "ORL", color: "openingRange", fromIndex: orFromIndex });
2359
+ if (Number.isFinite(ibHigh)) result.push({ price: ibHigh, label: "IBH", color: "initialBalance", fromIndex: ibFromIndex });
2360
+ if (Number.isFinite(ibLow)) result.push({ price: ibLow, label: "IBL", color: "initialBalance", fromIndex: ibFromIndex });
2361
+ return result;
2362
+ };
2363
+ var BUILTIN_SESSION_LEVELS_INDICATOR = {
2364
+ id: "session-levels",
2365
+ name: "Session levels",
2366
+ pane: "overlay",
2367
+ defaultInputs: {
2368
+ showPriorDay: true,
2369
+ showOvernight: true,
2370
+ showOpeningRange: true,
2371
+ showInitialBalance: false,
2372
+ // Cash-session bounds in the session's own zone; everything before the
2373
+ // open on the same trading day counts as overnight.
2374
+ rthStart: "09:30",
2375
+ rthEnd: "16:00",
2376
+ openingRangeMinutes: 30,
2377
+ priorDayColor: "#8b93a7",
2378
+ overnightColor: "#7c5cff",
2379
+ openingRangeColor: "#26a69a",
2380
+ initialBalanceColor: "#f7a600",
2381
+ lineWidth: 1,
2382
+ showLabels: true
2383
+ },
2384
+ draw: (ctx, renderContext, inputs) => {
2385
+ const { yFromPrice, chartLeft, chartRight, chartTop, chartBottom, data, session } = renderContext;
2386
+ if (!yFromPrice || !session || data.length === 0) return;
2387
+ const rthStart = parseMinutes(inputs.rthStart, 570);
2388
+ const rthEnd = parseMinutes(inputs.rthEnd, 960);
2389
+ const orMinutes = Math.max(1, Math.floor(Number(inputs.openingRangeMinutes) || 30));
2390
+ const levels = withCachedComputation(
2391
+ `session-levels|${rthStart}|${rthEnd}|${orMinutes}|${data.length}`,
2392
+ data,
2393
+ () => computeSessionLevels(data, session, rthStart, rthEnd, orMinutes)
2394
+ );
2395
+ const groupEnabled = {
2396
+ prior: inputs.showPriorDay !== false,
2397
+ overnight: inputs.showOvernight !== false,
2398
+ openingRange: inputs.showOpeningRange !== false,
2399
+ initialBalance: inputs.showInitialBalance === true
2400
+ };
2401
+ const groupColor = {
2402
+ prior: inputs.priorDayColor ?? "#8b93a7",
2403
+ overnight: inputs.overnightColor ?? "#7c5cff",
2404
+ openingRange: inputs.openingRangeColor ?? "#26a69a",
2405
+ initialBalance: inputs.initialBalanceColor ?? "#f7a600"
2406
+ };
2407
+ ctx.save();
2408
+ ctx.lineWidth = Math.max(1, Number(inputs.lineWidth) || 1);
2409
+ ctx.font = "10px system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
2410
+ ctx.textAlign = "right";
2411
+ ctx.textBaseline = "bottom";
2412
+ for (const level of levels) {
2413
+ if (!groupEnabled[level.color]) continue;
2414
+ const y = yFromPrice(level.price);
2415
+ if (!Number.isFinite(y) || y < chartTop || y > chartBottom) continue;
2416
+ const startX = Math.max(chartLeft, renderContext.xFromIndex(level.fromIndex));
2417
+ const color = groupColor[level.color] ?? "#8b93a7";
2418
+ ctx.strokeStyle = color;
2419
+ ctx.setLineDash(level.color === "prior" ? [] : [5, 4]);
2420
+ ctx.beginPath();
2421
+ ctx.moveTo(startX, Math.round(y) + 0.5);
2422
+ ctx.lineTo(chartRight, Math.round(y) + 0.5);
2423
+ ctx.stroke();
2424
+ if (inputs.showLabels !== false) {
2425
+ ctx.fillStyle = color;
2426
+ ctx.fillText(level.label, chartRight - 4, y - 2);
2427
+ }
2428
+ }
2429
+ ctx.restore();
2430
+ }
2431
+ };
2432
+ var BUILTIN_ANCHORED_VWAP_INDICATOR = {
2433
+ id: "anchored-vwap",
2434
+ name: "Anchored VWAP",
2435
+ pane: "overlay",
2436
+ defaultInputs: {
2437
+ anchorTime: "",
2438
+ color: "#22d3ee",
2439
+ width: 2,
2440
+ showBands: false,
2441
+ bandMultiplier: 1,
2442
+ bandColor: "#94a3b8",
2443
+ bandFillOpacity: 0.06,
2444
+ showAnchorMarker: true
2445
+ },
2446
+ draw: (ctx, renderContext, inputs) => {
2447
+ const { data, yFromPrice } = renderContext;
2448
+ if (!yFromPrice || data.length === 0) return;
2449
+ const anchorIndex = resolveAnchorIndex(renderContext, inputs.anchorTime);
2450
+ if (anchorIndex === null) return;
2451
+ const series = withCachedComputation(
2452
+ `anchored-vwap|${anchorIndex}|${inputs.showBands ? "b" : "n"}`,
2453
+ data,
2454
+ () => computeAnchoredVwap(data, anchorIndex)
2455
+ );
2456
+ if (inputs.showBands) {
2457
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
2458
+ const upper = series.vwap.map((value, idx) => {
2459
+ const sigma = series.sigma[idx];
2460
+ return value == null || sigma == null ? null : value + multiplier * sigma;
2461
+ });
2462
+ const lower = series.vwap.map((value, idx) => {
2463
+ const sigma = series.sigma[idx];
2464
+ return value == null || sigma == null ? null : value - multiplier * sigma;
2465
+ });
2466
+ const bandColor = inputs.bandColor ?? "#94a3b8";
2467
+ fillBetweenSeries(ctx, renderContext, upper, lower, bandColor, Number(inputs.bandFillOpacity) || 0.06);
2468
+ drawOverlaySeries(ctx, renderContext, upper, bandColor, 1);
2469
+ drawOverlaySeries(ctx, renderContext, lower, bandColor, 1);
2470
+ }
2471
+ drawOverlaySeries(ctx, renderContext, series.vwap, inputs.color ?? "#22d3ee", Number(inputs.width) || 2);
2472
+ if (inputs.showAnchorMarker !== false) {
2473
+ const anchorBar = data[anchorIndex];
2474
+ const anchorValue = series.vwap[anchorIndex];
2475
+ if (anchorBar && anchorValue != null) {
2476
+ const x = renderContext.xFromIndex(anchorIndex);
2477
+ const y = yFromPrice(anchorValue);
2478
+ if (x >= renderContext.chartLeft - 8 && x <= renderContext.chartRight + 8) {
2479
+ ctx.save();
2480
+ ctx.fillStyle = inputs.color ?? "#22d3ee";
2481
+ ctx.beginPath();
2482
+ ctx.arc(x, y, 3.5, 0, Math.PI * 2);
2483
+ ctx.fill();
2484
+ ctx.restore();
2485
+ }
2486
+ }
2487
+ }
2488
+ },
2489
+ getAutoscaleRange: (data, startIndex, endIndex, inputs, skipIndex) => {
2490
+ const anchorIndex = resolveAnchorIndexFromData(data, inputs.anchorTime);
2491
+ if (anchorIndex === null) return null;
2492
+ const series = withCachedComputation(
2493
+ `anchored-vwap|${anchorIndex}|${inputs.showBands ? "b" : "n"}`,
2494
+ data,
2495
+ () => computeAnchoredVwap(data, anchorIndex)
2496
+ );
2497
+ if (!inputs.showBands) return rangeOfSeries([series.vwap], startIndex, endIndex, skipIndex);
2498
+ const multiplier = Math.max(0.1, Number(inputs.bandMultiplier) || 1);
2499
+ let min = Number.POSITIVE_INFINITY;
2500
+ let max = Number.NEGATIVE_INFINITY;
2501
+ for (let idx = startIndex; idx <= endIndex; idx += 1) {
2502
+ if (idx === skipIndex) continue;
2503
+ const value = series.vwap[idx];
2504
+ const sigma = series.sigma[idx];
2505
+ if (value == null || sigma == null) continue;
2506
+ const lo = value - multiplier * sigma;
2507
+ const hi = value + multiplier * sigma;
2508
+ if (lo < min) min = lo;
2509
+ if (hi > max) max = hi;
2510
+ }
2511
+ return min <= max ? { min, max } : null;
2512
+ }
2513
+ };
2514
+ var resolveAnchorIndexFromData = (data, anchorTime) => {
2515
+ if (data.length === 0) return null;
2516
+ const raw = typeof anchorTime === "number" ? anchorTime : Date.parse(String(anchorTime ?? ""));
2517
+ if (!Number.isFinite(raw)) return null;
2518
+ let lo = 0;
2519
+ let hi = data.length - 1;
2520
+ if (raw <= data[0].time.getTime()) return 0;
2521
+ if (raw >= data[hi].time.getTime()) return hi;
2522
+ while (lo < hi) {
2523
+ const mid = lo + hi + 1 >> 1;
2524
+ if (data[mid].time.getTime() <= raw) lo = mid;
2525
+ else hi = mid - 1;
2526
+ }
2527
+ return lo;
2528
+ };
2529
+ var resolveAnchorIndex = (renderContext, anchorTime) => {
2530
+ const explicit = resolveAnchorIndexFromData(renderContext.data, anchorTime);
2531
+ if (explicit !== null) return explicit;
2532
+ const session = renderContext.session;
2533
+ const lastIndex = renderContext.data.length - 1;
2534
+ if (!session || lastIndex < 0) return lastIndex >= 0 ? 0 : null;
2535
+ const day = session.dayAt(lastIndex);
2536
+ let index = lastIndex;
2537
+ while (index > 0 && session.dayAt(index - 1) === day) index -= 1;
2538
+ return index;
2539
+ };
2540
+ var computeAnchoredVwap = (data, anchorIndex) => {
2541
+ const vwap = new Array(data.length).fill(null);
2542
+ const sigma = new Array(data.length).fill(null);
2543
+ let cumPv = 0;
2544
+ let cumPv2 = 0;
2545
+ let cumV = 0;
2546
+ for (let i = Math.max(0, anchorIndex); i < data.length; i += 1) {
2547
+ const bar = data[i];
2548
+ if (!bar) continue;
2549
+ const typical = (bar.h + bar.l + bar.c) / 3;
2550
+ const volume = Math.max(0, bar.v ?? 0) || 1;
2551
+ cumPv += typical * volume;
2552
+ cumPv2 += typical * typical * volume;
2553
+ cumV += volume;
2554
+ if (cumV <= 0) continue;
2555
+ const value = cumPv / cumV;
2556
+ vwap[i] = value;
2557
+ sigma[i] = Math.sqrt(Math.max(0, cumPv2 / cumV - value * value));
2558
+ }
2559
+ return { vwap, sigma };
2560
+ };
2074
2561
  var BUILTIN_INDICATORS = [
2075
2562
  BUILTIN_VOLUME_INDICATOR,
2076
2563
  BUILTIN_SMA_INDICATOR,
@@ -2098,7 +2585,10 @@ var BUILTIN_INDICATORS = [
2098
2585
  BUILTIN_SUPERTREND_INDICATOR,
2099
2586
  BUILTIN_ICHIMOKU_INDICATOR,
2100
2587
  BUILTIN_KELTNER_INDICATOR,
2101
- BUILTIN_DONCHIAN_INDICATOR
2588
+ BUILTIN_DONCHIAN_INDICATOR,
2589
+ BUILTIN_VOLUME_PROFILE_INDICATOR,
2590
+ BUILTIN_SESSION_LEVELS_INDICATOR,
2591
+ BUILTIN_ANCHORED_VWAP_INDICATOR
2102
2592
  ];
2103
2593
 
2104
2594
  // src/scripts.ts
@@ -3442,6 +3932,9 @@ function createChart(element, options = {}) {
3442
3932
  pointValue: Number(drawing.pointValue) || 1,
3443
3933
  qtyPrecision: Number.isFinite(drawing.qtyPrecision) ? Math.max(0, Math.floor(Number(drawing.qtyPrecision))) : 0,
3444
3934
  fontSize: Math.max(6, Number(drawing.fontSize) || 14),
3935
+ profileRows: Math.max(4, Math.min(200, Math.round(Number(drawing.profileRows)) || 24)),
3936
+ profileValueAreaPercent: Math.max(10, Math.min(99, Number(drawing.profileValueAreaPercent) || 70)),
3937
+ profileWidthRatio: Math.max(0.1, Math.min(1, Number(drawing.profileWidthRatio) || 0.5)),
3445
3938
  ...drawing.label === void 0 ? {} : { label: drawing.label }
3446
3939
  };
3447
3940
  };
@@ -3475,6 +3968,9 @@ function createChart(element, options = {}) {
3475
3968
  pointValue: drawing.pointValue,
3476
3969
  qtyPrecision: drawing.qtyPrecision,
3477
3970
  fontSize: drawing.fontSize,
3971
+ profileRows: drawing.profileRows,
3972
+ profileValueAreaPercent: drawing.profileValueAreaPercent,
3973
+ profileWidthRatio: drawing.profileWidthRatio,
3478
3974
  ...drawing.label === void 0 ? {} : { label: drawing.label }
3479
3975
  });
3480
3976
  let tradeMarkers = [];
@@ -3494,6 +3990,44 @@ function createChart(element, options = {}) {
3494
3990
  let compiledSession = compileSession(sessionOptions.spec);
3495
3991
  const isHour12 = () => timeFormat === "12h";
3496
3992
  const formatClock = (time) => zoneClock.formatTime(time.getTime(), isHour12());
3993
+ let sessionInfoCache = [];
3994
+ let sessionInfoCacheKey = "";
3995
+ const sessionInfoForIndex = (index) => {
3996
+ if (!compiledSession) return null;
3997
+ const point = data[index];
3998
+ if (!point) return null;
3999
+ const key = `${data.length}|${data[0]?.time.getTime() ?? 0}|${data[data.length - 1]?.time.getTime() ?? 0}|${sessionOptions.spec === null ? "none" : JSON.stringify(sessionOptions.spec)}`;
4000
+ if (key !== sessionInfoCacheKey) {
4001
+ sessionInfoCacheKey = key;
4002
+ sessionInfoCache = new Array(data.length);
4003
+ }
4004
+ const cached = sessionInfoCache[index];
4005
+ if (cached) return cached;
4006
+ const ms = point.time.getTime();
4007
+ const info = sessionInfoAt(compiledSession, ms);
4008
+ const resolved = {
4009
+ day: info.sessionDay,
4010
+ inSession: info.inSession,
4011
+ minutes: compiledSession.clock.minutesOfDay(ms)
4012
+ };
4013
+ sessionInfoCache[index] = resolved;
4014
+ return resolved;
4015
+ };
4016
+ const buildIndicatorSessionContext = () => {
4017
+ if (!compiledSession) return void 0;
4018
+ const dayAt = (index) => sessionInfoForIndex(index)?.day ?? Number.NaN;
4019
+ return {
4020
+ dayAt,
4021
+ inSessionAt: (index) => sessionInfoForIndex(index)?.inSession ?? false,
4022
+ isSessionStart: (index) => {
4023
+ if (index <= 0) return index === 0;
4024
+ const current = dayAt(index);
4025
+ const previous = dayAt(index - 1);
4026
+ return Number.isFinite(current) && Number.isFinite(previous) && current !== previous;
4027
+ },
4028
+ minutesOfDayAt: (index) => sessionInfoForIndex(index)?.minutes ?? Number.NaN
4029
+ };
4030
+ };
3497
4031
  let barMarks = [];
3498
4032
  let timescaleMarks = [];
3499
4033
  let markRegions = [];
@@ -6143,6 +6677,81 @@ function createChart(element, options = {}) {
6143
6677
  handleAt(ax, ay, drawing.color);
6144
6678
  handleAt(bx, by, drawing.color);
6145
6679
  }
6680
+ } else if (drawing.type === "fixed-range-volume-profile") {
6681
+ const p0 = drawing.points[0];
6682
+ const p1 = drawing.points[1];
6683
+ if (p0 && p1 && data.length > 0) {
6684
+ const fromIndex = Math.max(0, Math.round(Math.min(p0.index, p1.index)));
6685
+ const toIndex = Math.min(data.length - 1, Math.round(Math.max(p0.index, p1.index)));
6686
+ const leftX = xFromDrawingPoint(p0.index <= p1.index ? p0 : p1) - candleSpacing / 2;
6687
+ const rightX = xFromDrawingPoint(p0.index <= p1.index ? p1 : p0) + candleSpacing / 2;
6688
+ const rangeWidth = Math.max(1, rightX - leftX);
6689
+ const rows = Math.max(4, Math.min(200, Math.round(drawing.profileRows) || 24));
6690
+ const valueAreaPercent = Math.max(10, Math.min(99, Number(drawing.profileValueAreaPercent) || 70));
6691
+ const profile = toIndex >= fromIndex ? computeVolumeProfile(data, fromIndex, toIndex, rows, valueAreaPercent) : null;
6692
+ ctx.save();
6693
+ ctx.globalAlpha = draft ? 0.5 : 1;
6694
+ ctx.fillStyle = hexToRgba(drawing.color, 0.06);
6695
+ ctx.fillRect(leftX, chartTop, rangeWidth, chartBottom - chartTop);
6696
+ ctx.strokeStyle = hexToRgba(drawing.color, 0.5);
6697
+ ctx.lineWidth = 1;
6698
+ ctx.setLineDash([4, 3]);
6699
+ ctx.beginPath();
6700
+ ctx.moveTo(crisp(leftX), chartTop);
6701
+ ctx.lineTo(crisp(leftX), chartBottom);
6702
+ ctx.moveTo(crisp(rightX), chartTop);
6703
+ ctx.lineTo(crisp(rightX), chartBottom);
6704
+ ctx.stroke();
6705
+ ctx.setLineDash([]);
6706
+ if (profile && profile.maxRowVolume > 0) {
6707
+ const widthRatio = Math.max(0.1, Math.min(1, Number(drawing.profileWidthRatio) || 0.5));
6708
+ const maxWidth = rangeWidth * widthRatio;
6709
+ for (let row = 0; row < profile.rows.length; row += 1) {
6710
+ const bucket = profile.rows[row];
6711
+ if (bucket.total <= 0) continue;
6712
+ const yTop = yFromPrice(bucket.high);
6713
+ const yBottom = yFromPrice(bucket.low);
6714
+ const top = Math.min(yTop, yBottom);
6715
+ const height2 = Math.max(1, Math.abs(yBottom - yTop) - 1);
6716
+ if (top > chartBottom || top + height2 < chartTop) continue;
6717
+ const inValueArea = row >= profile.valueAreaFrom && row <= profile.valueAreaTo;
6718
+ ctx.globalAlpha = (draft ? 0.5 : 1) * (inValueArea ? 0.5 : 0.2);
6719
+ const rowWidth = bucket.total / profile.maxRowVolume * maxWidth;
6720
+ const upWidth = rowWidth * (bucket.up / bucket.total);
6721
+ ctx.fillStyle = mergedOptions.upColor;
6722
+ ctx.fillRect(leftX, top, upWidth, height2);
6723
+ ctx.fillStyle = mergedOptions.downColor;
6724
+ ctx.fillRect(leftX + upWidth, top, rowWidth - upWidth, height2);
6725
+ }
6726
+ ctx.globalAlpha = draft ? 0.6 : 1;
6727
+ const poc = profile.rows[profile.pocIndex];
6728
+ const drawProfileLevel = (price, color, label, dashed) => {
6729
+ const y = yFromPrice(price);
6730
+ if (y < chartTop || y > chartBottom) return;
6731
+ ctx.strokeStyle = color;
6732
+ ctx.lineWidth = 1;
6733
+ ctx.setLineDash(dashed ? [4, 3] : []);
6734
+ ctx.beginPath();
6735
+ ctx.moveTo(leftX, crisp(y));
6736
+ ctx.lineTo(leftX + maxWidth, crisp(y));
6737
+ ctx.stroke();
6738
+ ctx.setLineDash([]);
6739
+ const prevFont = ctx.font;
6740
+ ctx.font = `10px ${mergedOptions.fontFamily}`;
6741
+ ctx.fillStyle = color;
6742
+ ctx.textAlign = "left";
6743
+ ctx.textBaseline = "bottom";
6744
+ ctx.fillText(label, leftX + 3, y - 1);
6745
+ ctx.font = prevFont;
6746
+ };
6747
+ drawProfileLevel(profile.rows[profile.valueAreaTo].high, "#7e8aa3", "VAH", true);
6748
+ drawProfileLevel(profile.rows[profile.valueAreaFrom].low, "#7e8aa3", "VAL", true);
6749
+ drawProfileLevel((poc.low + poc.high) / 2, "#f7a600", "POC", false);
6750
+ }
6751
+ ctx.restore();
6752
+ handleAt(leftX, yFromPrice(p0.index <= p1.index ? p0.price : p1.price), drawing.color);
6753
+ handleAt(rightX, yFromPrice(p0.index <= p1.index ? p1.price : p0.price), drawing.color);
6754
+ }
6146
6755
  } else if (drawing.type === "price-range") {
6147
6756
  const p0 = drawing.points[0];
6148
6757
  const p1 = drawing.points[1];
@@ -6900,6 +7509,7 @@ function createChart(element, options = {}) {
6900
7509
  ).sort((a, b) => a.indicator.zIndex - b.indicator.zIndex);
6901
7510
  if (activeOverlayIndicators.length > 0) {
6902
7511
  const xFromIndex = (index) => chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
7512
+ const indicatorSession = buildIndicatorSessionContext();
6903
7513
  activeOverlayIndicators.forEach(({ indicator, plugin }) => {
6904
7514
  plugin.draw(
6905
7515
  ctx,
@@ -6921,7 +7531,8 @@ function createChart(element, options = {}) {
6921
7531
  getVolumeByIndex,
6922
7532
  candleSpacing,
6923
7533
  upColor: mergedOptions.upColor,
6924
- downColor: mergedOptions.downColor
7534
+ downColor: mergedOptions.downColor,
7535
+ ...indicatorSession ? { session: indicatorSession } : {}
6925
7536
  },
6926
7537
  indicator.inputs
6927
7538
  );
@@ -7721,8 +8332,17 @@ function createChart(element, options = {}) {
7721
8332
  return typeof value === "string" && !/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value.trim());
7722
8333
  };
7723
8334
  const LEGEND_EXCLUDED_INPUT_KEYS = /* @__PURE__ */ new Set(["width", "bandMultiplier", "bandFillOpacity", "fillOpacity"]);
8335
+ const formatLegendValue = (value) => {
8336
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
8337
+ const ms = Date.parse(value);
8338
+ if (Number.isFinite(ms)) {
8339
+ return getTimeStepMs() < 864e5 ? `${zoneClock.formatDayMonth(ms)} ${zoneClock.formatTime(ms, isHour12())}` : zoneClock.formatDayMonth(ms);
8340
+ }
8341
+ }
8342
+ return String(value);
8343
+ };
7724
8344
  const labelEntries = activeOverlayIndicators.map(({ indicator, plugin }) => {
7725
- 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));
8345
+ 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));
7726
8346
  if (labels.showIndicatorNames && labels.showIndicatorValues && inputValues.length > 0) {
7727
8347
  return `${plugin.name} ${inputValues.join(" ")}`;
7728
8348
  }
@@ -8904,6 +9524,19 @@ function createChart(element, options = {}) {
8904
9524
  if (distanceToSegment(x, y, bx + blockW / 2, by + blockH / 2, ax, ay) <= 5 * hitTolerance) {
8905
9525
  return { drawing, target: "line" };
8906
9526
  }
9527
+ } else if (drawing.type === "fixed-range-volume-profile") {
9528
+ const p0 = drawing.points[0];
9529
+ const p1 = drawing.points[1];
9530
+ if (!p0 || !p1) continue;
9531
+ const px0 = canvasXFromDrawingPoint(p0);
9532
+ const px1 = canvasXFromDrawingPoint(p1);
9533
+ const y0 = canvasYFromDrawingPrice(p0.price);
9534
+ const y1 = canvasYFromDrawingPrice(p1.price);
9535
+ if (Math.hypot(x - px0, y - y0) <= 9 * hitTolerance) return { drawing, target: "handle", pointIndex: 0 };
9536
+ if (Math.hypot(x - px1, y - y1) <= 9 * hitTolerance) return { drawing, target: "handle", pointIndex: 1 };
9537
+ if (x >= Math.min(px0, px1) && x <= Math.max(px0, px1)) {
9538
+ return { drawing, target: "line" };
9539
+ }
8907
9540
  } else if (drawing.type === "price-range") {
8908
9541
  const p0 = drawing.points[0];
8909
9542
  const p1 = drawing.points[1];
@@ -9355,6 +9988,33 @@ function createChart(element, options = {}) {
9355
9988
  scheduleDraw();
9356
9989
  return true;
9357
9990
  }
9991
+ if (activeDrawingTool === "fixed-range-volume-profile") {
9992
+ if (draftDrawing?.type === "fixed-range-volume-profile") {
9993
+ const completed = normalizeDrawingState({
9994
+ ...serializeDrawing(draftDrawing),
9995
+ points: [draftDrawing.points[0], point]
9996
+ });
9997
+ drawings.push(completed);
9998
+ draftDrawing = null;
9999
+ activeDrawingTool = null;
10000
+ emitDrawingsChange();
10001
+ scheduleDraw();
10002
+ return true;
10003
+ }
10004
+ const defaults = getDrawingToolDefaults("fixed-range-volume-profile");
10005
+ draftDrawing = normalizeDrawingState({
10006
+ type: "fixed-range-volume-profile",
10007
+ points: [point, point],
10008
+ color: defaults.color ?? "#5b8def",
10009
+ style: defaults.style ?? "solid",
10010
+ width: defaults.width ?? 1,
10011
+ ...defaults.profileRows === void 0 ? {} : { profileRows: defaults.profileRows },
10012
+ ...defaults.profileValueAreaPercent === void 0 ? {} : { profileValueAreaPercent: defaults.profileValueAreaPercent },
10013
+ ...defaults.profileWidthRatio === void 0 ? {} : { profileWidthRatio: defaults.profileWidthRatio }
10014
+ });
10015
+ scheduleDraw();
10016
+ return true;
10017
+ }
9358
10018
  if (activeDrawingTool === "price-range") {
9359
10019
  const tick = getConfiguredTickSize();
9360
10020
  const visibleRange = drawState.yMax - drawState.yMin;