/** * Equity volume-by-price profile from OHLC bars (not options OI). * Uniform volume split across bins that a bar's low–high spans. */ export interface VolumeProfileCandle { h: number; l: number; c?: number; v: number; } export interface VolumeProfileBin { priceLo: number; priceHi: number; mid: number; volume: number; } export interface VolumeProfileResult { bins: VolumeProfileBin[]; /** Mid-price of the highest-volume bin. */ pocMid: number | null; /** Value-area high / low (mids of outer bins in the value area). */ vah: number | null; val: number | null; totalVolume: number; } export interface BuildVolumeProfileOpts { /** Number of price bins. Default 32. */ bins?: number; /** Fraction of volume for value area (e.g. 0.7). Default 0.7. */ valueAreaPct?: number; } /** * Build a volume profile from candles in the visible range. * Empty or zero-volume inputs return empty bins. */ export function buildVolumeProfile( candles: VolumeProfileCandle[], opts: BuildVolumeProfileOpts = {}, ): VolumeProfileResult { const binCount = Math.max(2, Math.min(80, Math.floor(opts.bins ?? 32))); const valueAreaPct = Math.min(0.99, Math.max(0.5, opts.valueAreaPct ?? 0.7)); const usable = candles.filter( (c) => Number.isFinite(c.h) && Number.isFinite(c.l) && Number.isFinite(c.v) && c.v > 0 && c.h >= c.l, ); if (usable.length === 0) { return { bins: [], pocMid: null, vah: null, val: null, totalVolume: 0 }; } let priceMin = Infinity; let priceMax = -Infinity; let totalVolume = 0; for (const c of usable) { if (c.l < priceMin) priceMin = c.l; if (c.h > priceMax) priceMax = c.h; totalVolume += c.v; } // Flat range: single effective level if (!(priceMax > priceMin)) { const mid = priceMin; const bins: VolumeProfileBin[] = [ { priceLo: mid, priceHi: mid, mid, volume: totalVolume }, ]; return { bins, pocMid: mid, vah: mid, val: mid, totalVolume }; } const width = (priceMax - priceMin) / binCount; const volumes = new Array(binCount).fill(0); for (const c of usable) { const lo = c.l; const hi = c.h; // Inclusive bins from low to high let i0 = Math.floor((lo - priceMin) / width); let i1 = Math.floor((hi - priceMin) / width); if (i0 < 0) i0 = 0; if (i1 >= binCount) i1 = binCount - 1; if (i1 < i0) i1 = i0; const n = i1 - i0 + 1; const share = c.v / n; for (let i = i0; i <= i1; i++) volumes[i] += share; } const bins: VolumeProfileBin[] = volumes.map((volume, i) => { const priceLo = priceMin + i * width; const priceHi = i === binCount - 1 ? priceMax : priceMin + (i + 1) * width; return { priceLo, priceHi, mid: (priceLo + priceHi) / 2, volume, }; }); // POC = max volume bin let pocIdx = 0; for (let i = 1; i < bins.length; i++) { if (bins[i].volume > bins[pocIdx].volume) pocIdx = i; } // Value area: expand from POC until we cover valueAreaPct of total volume const target = totalVolume * valueAreaPct; let lo = pocIdx; let hi = pocIdx; let covered = bins[pocIdx].volume; while (covered < target && (lo > 0 || hi < bins.length - 1)) { const nextLo = lo > 0 ? bins[lo - 1].volume : -1; const nextHi = hi < bins.length - 1 ? bins[hi + 1].volume : -1; if (nextHi >= nextLo) { hi++; covered += bins[hi].volume; } else { lo--; covered += bins[lo].volume; } } return { bins, pocMid: bins[pocIdx].mid, vah: bins[hi].priceHi, val: bins[lo].priceLo, totalVolume, }; }