feat(analysis): add technical indicator coverage for confluence signal engine
CI / Test & Type-Check (push) Canceled after 0s

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.
This commit is contained in:
Investor Flow Build
2026-08-10 14:12:35 -04:00
parent ac94acf9e3
commit ef2c39167c
4 changed files with 663 additions and 1 deletions
@@ -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);
});
});
@@ -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);
});
});