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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user