// Investor Flow — Volume-by-price profile (backend port). // Pure function. Zero external dependencies. export interface VolumeProfileBin { priceLo: number; priceHi: number; mid: number; volume: number; } export interface VolumeProfileResult { bins: VolumeProfileBin[]; pocMid: number | null; vah: number | null; val: number | null; totalVolume: number; } export interface BuildVolumeProfileOpts { bins?: number; valueAreaPct?: number; } /** * Build a volume profile from candles. Uniform volume split across bins that a * bar's low-high spans. Empty or zero-volume inputs return empty bins. */ export function buildVolumeProfile( candles: { h: number; l: number; v: number }[], 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; } 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; 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, }; }); let pocIdx = 0; for (let i = 1; i < bins.length; i++) { if (bins[i].volume > bins[pocIdx].volume) pocIdx = i; } 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, }; }