feat(analysis): add technical indicator coverage for confluence signal engine
CI / Test & Type-Check (push) Canceled after 0s
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user