Files
investor-flow/app/src/lib/volumeProfile.ts
T

138 lines
3.5 KiB
TypeScript
Raw Normal View History

/**
* 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<number>(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,
};
}