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);
});
});
+233
View File
@@ -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<number, number>; extensions: Record<number, number> } {
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<number, number> = {};
for (const r of retracementRatios) {
retracements[r] = swingHigh - range * r;
}
const extensions: Record<number, number> = {};
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<number, number>; extensions: Record<number, number> },
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;
}
+120
View File
@@ -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<number>(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,
};
}