From ef2c39167c18aaa12689b3f2c64b5fe885efd425 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 10 Aug 2026 14:12:35 -0400 Subject: [PATCH] feat(analysis): add technical indicator coverage for confluence signal engine New functions in indicators.ts: - sma: Simple Moving Average - crossover: golden/death cross detector - roc: Rate of Change (momentum) - timeSeriesMomentum: 12-1 month momentum (Moskowitz et al.) - relativeStrength: symbol vs benchmark momentum differential - realizedVol: annualized realized volatility from log returns - volatilityPercentile: percentile rank of current vol vs history - fibLevels: Fibonacci retracement and extension levels - fibCluster: proximity check for Fib confluence zones New file volumeByPrice.ts: - buildVolumeProfile: ported from frontend volumeProfile.ts - Identical algorithm: bin clamping, uniform volume split, POC/VAH/VAL All functions are pure (zero deps, no I/O). Tests added for all new functions with edge cases, error handling, and numerical correctness. --- .../src/analysis/__tests__/indicators.test.ts | 223 ++++++++++++++++- .../analysis/__tests__/volumeByPrice.test.ts | 88 +++++++ app/server/src/analysis/indicators.ts | 233 ++++++++++++++++++ app/server/src/analysis/volumeByPrice.ts | 120 +++++++++ 4 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 app/server/src/analysis/__tests__/volumeByPrice.test.ts create mode 100644 app/server/src/analysis/volumeByPrice.ts diff --git a/app/server/src/analysis/__tests__/indicators.test.ts b/app/server/src/analysis/__tests__/indicators.test.ts index b09c81b..2e8a868 100644 --- a/app/server/src/analysis/__tests__/indicators.test.ts +++ b/app/server/src/analysis/__tests__/indicators.test.ts @@ -4,7 +4,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { ema, rsi, relativeVolume, emaFromCandles } from '../indicators.ts'; +import { ema, rsi, relativeVolume, emaFromCandles, sma, crossover, roc, timeSeriesMomentum, relativeStrength, realizedVol, volatilityPercentile, fibLevels, fibCluster } from '../indicators.ts'; import type { PriceCandle } from '../../cache/CacheRepository.ts'; describe('ema', () => { @@ -205,3 +205,224 @@ describe('emaFromCandles', () => { assert.deepEqual(result, []); }); }); + +describe('sma', () => { + it('returns undefined before period - 1', () => { + const result = sma([1, 2, 3, 4, 5], 3); + assert.equal(result[0], undefined); + assert.equal(result[1], undefined); + }); + + it('computes correct average at period - 1', () => { + const result = sma([10, 20, 30, 40, 50], 3); + assert.equal(result[2], 20); + }); + + it('computes a sliding window after period - 1', () => { + const result = sma([10, 20, 30, 40, 50], 3); + assert.equal(result[3], 30); + assert.equal(result[4], 40); + }); + + it('throws on non-positive period', () => { + assert.throws(() => sma([1, 2, 3], 0)); + assert.throws(() => sma([1, 2, 3], -1)); + }); + + it('returns all undefined when values.length < period', () => { + const result = sma([1, 2], 5); + for (const v of result) assert.equal(v, undefined); + }); +}); + +describe('crossover', () => { + it('detects a golden cross (fast crosses above slow)', () => { + const fast = [undefined, 10, 12]; + const slow = [undefined, 15, 11]; + const result = crossover(fast, slow); + assert.equal(result[2], 'golden'); + }); + + it('detects a death cross (fast crosses below slow)', () => { + const fast = [undefined, 20, 8]; + const slow = [undefined, 15, 12]; + const result = crossover(fast, slow); + assert.equal(result[2], 'death'); + }); + + it('returns undefined when no cross occurs', () => { + const fast = [undefined, 20, 25]; + const slow = [undefined, 10, 15]; + const result = crossover(fast, slow); + assert.equal(result[2], undefined); + }); + + it('returns undefined when inputs are undefined', () => { + const fast = [undefined, undefined, 20]; + const slow = [undefined, 10, 15]; + const result = crossover(fast, slow); + assert.equal(result[2], undefined); + }); +}); + +describe('roc', () => { + it('returns undefined for i < period', () => { + const result = roc([100, 105, 110, 115], 3); + assert.equal(result[0], undefined); + assert.equal(result[1], undefined); + assert.equal(result[2], undefined); + }); + + it('computes correct percentage at i = period', () => { + const result = roc([100, 105, 110, 115], 3); + const expected = (115 / 100 - 1) * 100; + assert.ok(Math.abs(result[3]! - expected) < 1e-9); + }); + + it('returns zero ROC when price is unchanged', () => { + const result = roc([50, 50, 50, 50], 2); + assert.equal(result[2], 0); + assert.equal(result[3], 0); + }); + + it('throws on non-positive period', () => { + assert.throws(() => roc([1, 2], 0)); + assert.throws(() => roc([1, 2], -1)); + }); +}); + +describe('timeSeriesMomentum', () => { + it('returns undefined for i < lookback (default 252)', () => { + const values = new Array(300).fill(0).map((_, i) => 100 + i); + const result = timeSeriesMomentum(values); + for (let i = 0; i < 252; i++) assert.equal(result[i], undefined); + }); + + it('computes correct 12-1 return at i = lookback', () => { + const values: number[] = []; + for (let i = 0; i < 260; i++) values.push(100 + i); + const result = timeSeriesMomentum(values, 252, 21); + const expected = (values[252 - 21] / values[0] - 1) * 100; + assert.ok(Math.abs(result[252]! - expected) < 1e-9); + }); + + it('throws on non-positive lookback', () => { + assert.throws(() => timeSeriesMomentum([1, 2, 3], 0)); + }); + + it('throws on non-positive skip', () => { + assert.throws(() => timeSeriesMomentum([1, 2, 3], 10, 0)); + }); +}); + +describe('relativeStrength', () => { + it('returns positive when symbol outperforms', () => { + const sym = new Array(260).fill(0).map((_, i) => 100 + i * 2); + const ben = new Array(260).fill(0).map((_, i) => 100 + i); + const result = relativeStrength(sym, ben, 252, 21); + const last = result[259]; + assert.ok(last !== undefined && last > 0, `Expected positive, got ${last}`); + }); + + it('returns negative when symbol underperforms', () => { + const sym = new Array(260).fill(0).map((_, i) => 100 + i * 0.5); + const ben = new Array(260).fill(0).map((_, i) => 100 + i); + const result = relativeStrength(sym, ben, 252, 21); + const last = result[259]; + assert.ok(last !== undefined && last < 0, `Expected negative, got ${last}`); + }); + + it('returns undefined where either input is undefined', () => { + const sym = new Array(300).fill(100); + const ben = new Array(300).fill(100); + const result = relativeStrength(sym, ben, 252, 21); + assert.equal(result[0], undefined); + assert.equal(result[200], undefined); + }); + + it('throws on length mismatch', () => { + assert.throws(() => relativeStrength([1, 2, 3], [1, 2])); + }); +}); + +describe('realizedVol', () => { + it('returns undefined for i < period', () => { + const closes = [100, 101, 102, 103, 104]; + const result = realizedVol(closes, 20); + for (const v of result) assert.equal(v, undefined); + }); + + it('returns a positive value for a volatile series', () => { + const closes: number[] = []; + for (let i = 0; i < 50; i++) closes.push(100 + Math.sin(i * 0.3) * 10); + const result = realizedVol(closes, 20); + const last = result[49]; + assert.ok(last !== undefined && last > 0, `Expected positive vol, got ${last}`); + }); + + it('returns 0 for constant prices', () => { + const closes = new Array(30).fill(100); + const result = realizedVol(closes, 20); + assert.equal(result[29], 0); + }); +}); + +describe('volatilityPercentile', () => { + it('returns 0 for lowest vol in history', () => { + const closes: number[] = []; + for (let i = 0; i < 600; i++) closes.push(100); + closes.push(100.001); + const result = volatilityPercentile(closes, 20, 504); + const lastDefined = result[result.length - 1]; + assert.ok(lastDefined !== undefined, 'Expected a defined value'); + }); + + it('returns undefined when insufficient history', () => { + const closes = [100, 101, 102]; + const result = volatilityPercentile(closes, 20, 504); + for (const v of result) assert.equal(v, undefined); + }); + + it('produces values between 0 and 100', () => { + const closes: number[] = []; + for (let i = 0; i < 600; i++) closes.push(100 + Math.sin(i * 0.1) * 20); + const result = volatilityPercentile(closes, 20, 504); + for (const v of result) { + if (v !== undefined) { + assert.ok(v >= 0 && v <= 100, `Percentile out of range: ${v}`); + } + } + }); +}); + +describe('fibLevels', () => { + it('computes correct retracement levels for known high/low', () => { + const result = fibLevels(200, 100); + const range = 100; + assert.ok(Math.abs(result.retracements[0.236] - (200 - range * 0.236)) < 1e-9); + assert.ok(Math.abs(result.retracements[0.382] - (200 - range * 0.382)) < 1e-9); + assert.ok(Math.abs(result.retracements[0.5] - 150) < 1e-9); + assert.ok(Math.abs(result.retracements[0.618] - (200 - range * 0.618)) < 1e-9); + assert.ok(Math.abs(result.retracements[0.786] - (200 - range * 0.786)) < 1e-9); + }); + + it('extensions are above the swing high for ratios > 1', () => { + const result = fibLevels(200, 100); + assert.ok(result.extensions[1.272] > 200); + assert.ok(result.extensions[1.618] > 200); + assert.ok(result.extensions[2.618] > 200); + }); +}); + +describe('fibCluster', () => { + it('returns true when a Fib level is within tolerance of another level', () => { + const fib = fibLevels(200, 100); + const target = fib.retracements[0.5] + 0.5; + assert.equal(fibCluster(fib, [target], 0.01), true); + }); + + it('returns false when no level is close', () => { + const fib = fibLevels(200, 100); + assert.equal(fibCluster(fib, [9999], 0.01), false); + }); +}); diff --git a/app/server/src/analysis/__tests__/volumeByPrice.test.ts b/app/server/src/analysis/__tests__/volumeByPrice.test.ts new file mode 100644 index 0000000..b31e4e5 --- /dev/null +++ b/app/server/src/analysis/__tests__/volumeByPrice.test.ts @@ -0,0 +1,88 @@ +// Investor Flow — volumeByPrice.test.ts +// Pure-logic tests for the volume-by-price profile builder. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildVolumeProfile } from '../volumeByPrice.ts'; + +describe('buildVolumeProfile', () => { + it('returns empty bins for empty input', () => { + const result = buildVolumeProfile([]); + assert.deepEqual(result.bins, []); + assert.equal(result.pocMid, null); + assert.equal(result.vah, null); + assert.equal(result.val, null); + assert.equal(result.totalVolume, 0); + }); + + it('returns bins with all volume for a single candle', () => { + const result = buildVolumeProfile([{ h: 110, l: 100, v: 500 }]); + assert.ok(result.bins.length > 0); + assert.equal(result.totalVolume, 500); + assert.notEqual(result.pocMid, null); + assert.notEqual(result.vah, null); + assert.notEqual(result.val, null); + }); + + it('returns single bin for a flat price range', () => { + const result = buildVolumeProfile([ + { h: 50, l: 50, v: 100 }, + { h: 50, l: 50, v: 200 }, + ]); + assert.equal(result.bins.length, 1); + assert.equal(result.bins[0].volume, 300); + assert.equal(result.pocMid, 50); + assert.equal(result.vah, 50); + assert.equal(result.val, 50); + assert.equal(result.totalVolume, 300); + }); + + it('identifies POC as the highest-volume bin', () => { + const candles = [ + { h: 105, l: 95, v: 100 }, + { h: 115, l: 105, v: 500 }, + { h: 125, l: 115, v: 100 }, + ]; + const result = buildVolumeProfile(candles, { bins: 6 }); + const pocBin = result.bins.reduce((a, b) => (b.volume > a.volume ? b : a)); + assert.equal(result.pocMid, pocBin.mid); + assert.ok(pocBin.volume > 0, `POC volume should be > 0, got ${pocBin.volume}`); + }); + + it('value area covers approximately 70% of volume by default', () => { + const candles = [ + { h: 110, l: 100, v: 300 }, + { h: 120, l: 110, v: 400 }, + { h: 130, l: 120, v: 300 }, + { h: 140, l: 130, v: 200 }, + { h: 150, l: 140, v: 100 }, + ]; + const result = buildVolumeProfile(candles, { bins: 10 }); + assert.notEqual(result.val, null); + assert.notEqual(result.vah, null); + + const vaVolume = result.bins + .filter((b) => b.priceLo >= result.val! && b.priceHi <= result.vah!) + .reduce((s, b) => s + b.volume, 0); + const pct = vaVolume / result.totalVolume; + assert.ok(pct >= 0.65, `Value area should cover ~70%, got ${(pct * 100).toFixed(1)}%`); + }); + + it('clamps bin count to min 2, max 80', () => { + const candles = [{ h: 110, l: 100, v: 100 }]; + const r1 = buildVolumeProfile(candles, { bins: 1 }); + assert.ok(r1.bins.length >= 2, `Should clamp to min 2 bins, got ${r1.bins.length}`); + const r2 = buildVolumeProfile(candles, { bins: 200 }); + assert.ok(r2.bins.length <= 80, `Should clamp to max 80 bins, got ${r2.bins.length}`); + }); + + it('ignores candles with zero or negative volume', () => { + const result = buildVolumeProfile([ + { h: 110, l: 100, v: 0 }, + { h: 110, l: 100, v: -50 }, + { h: 110, l: 100, v: 200 }, + ]); + assert.equal(result.totalVolume, 200); + }); +}); diff --git a/app/server/src/analysis/indicators.ts b/app/server/src/analysis/indicators.ts index 0264cbe..380ab43 100644 --- a/app/server/src/analysis/indicators.ts +++ b/app/server/src/analysis/indicators.ts @@ -184,3 +184,236 @@ export function macd( return { macdLine, signalLine, histogram }; } + +/** + * Simple Moving Average. + * Returns an array aligned to `values`, with `undefined` for indices before + * `period - 1`. At `period - 1` the average of `values[0..period-1]`. + */ +export function sma(values: number[], period: number): (number | undefined)[] { + if (period <= 0) throw new Error(`period must be positive, got ${period}`); + const result: (number | undefined)[] = new Array(values.length); + + if (values.length < period) return result; + + let sum = 0; + for (let i = 0; i < period; i++) sum += values[i]; + result[period - 1] = sum / period; + + for (let i = period; i < values.length; i++) { + sum += values[i] - values[i - period]; + result[i] = sum / period; + } + + return result; +} + +/** + * Crossover detector for two aligned series. + * Returns `'golden'` when `fast` crosses above `slow`, `'death'` when `fast` + * crosses below `slow`, or `undefined` when no crossover occurs. + * Both values at `i` and `i-1` must be defined for a signal. + */ +export function crossover( + fast: (number | undefined)[], + slow: (number | undefined)[], +): ('golden' | 'death' | undefined)[] { + const len = Math.min(fast.length, slow.length); + const result: ('golden' | 'death' | undefined)[] = new Array(len); + + for (let i = 1; i < len; i++) { + const fCur = fast[i]; + const sCur = slow[i]; + const fPrev = fast[i - 1]; + const sPrev = slow[i - 1]; + if (fCur === undefined || sCur === undefined || fPrev === undefined || sPrev === undefined) continue; + if (fPrev <= sPrev && fCur > sCur) result[i] = 'golden'; + else if (fPrev >= sPrev && fCur < sCur) result[i] = 'death'; + } + + return result; +} + +/** + * Rate of Change (momentum). + * `ROC[i] = (values[i] / values[i - period] - 1) * 100`. + * Returns `undefined` for `i < period`. + */ +export function roc(values: number[], period: number): (number | undefined)[] { + if (period <= 0) throw new Error(`period must be positive, got ${period}`); + const result: (number | undefined)[] = new Array(values.length); + + for (let i = period; i < values.length; i++) { + result[i] = (values[i] / values[i - period] - 1) * 100; + } + + return result; +} + +/** + * 12-1 month time-series momentum (Moskowitz, Ooi, Pedersen 2012). + * Returns `(values[i - skip] / values[i - lookback] - 1) * 100`. + * Defaults: lookback=252 trading days, skip=21. + */ +export function timeSeriesMomentum( + values: number[], + lookback: number = 252, + skip: number = 21, +): (number | undefined)[] { + if (lookback <= 0) throw new Error(`lookback must be positive, got ${lookback}`); + if (skip <= 0) throw new Error(`skip must be positive, got ${skip}`); + const result: (number | undefined)[] = new Array(values.length); + + for (let i = lookback; i < values.length; i++) { + result[i] = (values[i - skip] / values[i - lookback] - 1) * 100; + } + + return result; +} + +/** + * Relative strength: difference in 12-1 momentum between symbol and benchmark. + * Positive = symbol outperforming. Both arrays must be the same length. + */ +export function relativeStrength( + symbolValues: number[], + benchmarkValues: number[], + lookback: number = 252, + skip: number = 21, +): (number | undefined)[] { + if (symbolValues.length !== benchmarkValues.length) { + throw new Error(`length mismatch: symbol=${symbolValues.length} benchmark=${benchmarkValues.length}`); + } + const symMom = timeSeriesMomentum(symbolValues, lookback, skip); + const benMom = timeSeriesMomentum(benchmarkValues, lookback, skip); + const result: (number | undefined)[] = new Array(symbolValues.length); + + for (let i = 0; i < result.length; i++) { + if (symMom[i] !== undefined && benMom[i] !== undefined) { + result[i] = symMom[i]! - benMom[i]!; + } + } + + return result; +} + +/** + * Annualized realized volatility from daily log returns over `period` bars. + * Annualization factor: sqrt(252). Returns `undefined` for `i < period`. + */ +export function realizedVol(closes: number[], period: number = 20): (number | undefined)[] { + if (period <= 0) throw new Error(`period must be positive, got ${period}`); + const result: (number | undefined)[] = new Array(closes.length); + + if (closes.length < period + 1) return result; + + const logReturns: number[] = new Array(closes.length - 1); + for (let i = 1; i < closes.length; i++) { + logReturns[i - 1] = Math.log(closes[i] / closes[i - 1]); + } + + const annualization = Math.sqrt(252); + + for (let i = period; i < logReturns.length; i++) { + let sum = 0; + for (let j = i - period + 1; j <= i; j++) sum += logReturns[j]; + const mean = sum / period; + + let sqSum = 0; + for (let j = i - period + 1; j <= i; j++) { + const d = logReturns[j] - mean; + sqSum += d * d; + } + result[i + 1] = Math.sqrt(sqSum / period) * annualization; + } + + return result; +} + +/** + * Percentile rank of current realized volatility vs its trailing history. + * Returns 0-100 (0 = lowest, 100 = highest). Defaults: volPeriod=20, lookback=504 (2y). + */ +export function volatilityPercentile( + closes: number[], + volPeriod: number = 20, + lookback: number = 504, +): (number | undefined)[] { + if (volPeriod <= 0) throw new Error(`volPeriod must be positive, got ${volPeriod}`); + if (lookback <= 0) throw new Error(`lookback must be positive, got ${lookback}`); + const rv = realizedVol(closes, volPeriod); + const result: (number | undefined)[] = new Array(closes.length); + + for (let i = 0; i < rv.length; i++) { + if (rv[i] === undefined) continue; + + const windowStart = Math.max(0, i - lookback); + const history: number[] = []; + for (let j = windowStart; j <= i; j++) { + if (rv[j] !== undefined) history.push(rv[j]!); + } + + if (history.length < 2) continue; + + let count = 0; + for (let k = 0; k < history.length; k++) { + if (history[k] <= rv[i]!) count++; + } + result[i] = ((count - 1) / (history.length - 1)) * 100; + } + + return result; +} + +/** + * Standard Fibonacci retracement and extension levels. + * Retracements: 0.236, 0.382, 0.5, 0.618, 0.786 + * Extensions: 1.272, 1.618, 2.618 + */ +export function fibLevels( + swingHigh: number, + swingLow: number, +): { retracements: Record; extensions: Record } { + const range = swingHigh - swingLow; + const retracementRatios = [0.236, 0.382, 0.5, 0.618, 0.786]; + const extensionRatios = [1.272, 1.618, 2.618]; + + const retracements: Record = {}; + for (const r of retracementRatios) { + retracements[r] = swingHigh - range * r; + } + + const extensions: Record = {}; + for (const r of extensionRatios) { + extensions[r] = swingLow + range * r; + } + + return { retracements, extensions }; +} + +/** + * Returns `true` if any Fibonacci retracement or extension level is within + * `tolerancePct` (fraction, e.g. 0.02 for 2%) of any level in `otherLevels`. + */ +export function fibCluster( + fib: { retracements: Record; extensions: Record }, + otherLevels: number[], + tolerancePct: number, +): boolean { + const allFibLevels = [ + ...Object.values(fib.retracements), + ...Object.values(fib.extensions), + ]; + + for (const fibVal of allFibLevels) { + for (const other of otherLevels) { + if (other === 0) { + if (Math.abs(fibVal) <= tolerancePct) return true; + continue; + } + if (Math.abs(fibVal - other) / Math.abs(other) <= tolerancePct) return true; + } + } + + return false; +} diff --git a/app/server/src/analysis/volumeByPrice.ts b/app/server/src/analysis/volumeByPrice.ts new file mode 100644 index 0000000..c111515 --- /dev/null +++ b/app/server/src/analysis/volumeByPrice.ts @@ -0,0 +1,120 @@ +// 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, + }; +}