Fixes from ornith's backend code review, implemented by ornith-35 (cross-review by qwopus35b pending). 129/129 tests green.
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
// Investor Flow — Pure technical-indicator functions.
|
|
// Zero external dependencies. Operate on raw number arrays or PriceCandle[].
|
|
|
|
import type { PriceCandle } from '../cache/CacheRepository.ts';
|
|
|
|
/**
|
|
* Exponential Moving Average.
|
|
* Returns an array aligned to `values`, with `undefined` for indices before the
|
|
* seed SMA is complete (i.e. before index `period - 1`). At `period - 1` the
|
|
* SMA of the first `period` values seeds the EMA; subsequent values use the
|
|
* standard EMA multiplier `2 / (period + 1)`.
|
|
*/
|
|
export function ema(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; // all undefined
|
|
}
|
|
|
|
// Seed: SMA of the first `period` values.
|
|
let sum = 0;
|
|
for (let i = 0; i < period; i++) sum += values[i];
|
|
const sma = sum / period;
|
|
result[period - 1] = sma;
|
|
|
|
const multiplier = 2 / (period + 1);
|
|
let emaVal = sma;
|
|
|
|
for (let i = period; i < values.length; i++) {
|
|
emaVal = (values[i] - emaVal) * multiplier + emaVal;
|
|
result[i] = emaVal;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Relative Strength Index (Wilder's smoothing).
|
|
* Returns an array aligned to `closes`, with `undefined` for indices before
|
|
* the period-th close. Computes on price changes (close[i] - close[i-1]).
|
|
*/
|
|
export function rsi(closes: number[], period: number = 14): (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; // all undefined — need at least `period` changes
|
|
}
|
|
|
|
// Compute price changes.
|
|
const deltas: number[] = new Array(closes.length - 1);
|
|
for (let i = 1; i < closes.length; i++) {
|
|
deltas[i - 1] = closes[i] - closes[i - 1];
|
|
}
|
|
|
|
// Wilder's smoothing: average gain / average loss over `period` changes.
|
|
let avgGain = 0;
|
|
let avgLoss = 0;
|
|
|
|
for (let i = 0; i < period; i++) {
|
|
if (deltas[i] > 0) avgGain += deltas[i];
|
|
else avgLoss += Math.abs(deltas[i]);
|
|
}
|
|
|
|
avgGain /= period;
|
|
avgLoss /= period;
|
|
|
|
// RSIs are defined starting at index `period` in the original closes array
|
|
// (because we need `period` changes, which starts at delta[0] = closes[1]-closes[0]).
|
|
result[period] = computeRsiFrom(avgGain, avgLoss);
|
|
|
|
for (let i = period; i < deltas.length; i++) {
|
|
const gain = deltas[i] > 0 ? deltas[i] : 0;
|
|
const loss = deltas[i] < 0 ? Math.abs(deltas[i]) : 0;
|
|
avgGain = (avgGain * (period - 1) + gain) / period;
|
|
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
|
result[i + 1] = computeRsiFrom(avgGain, avgLoss);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function computeRsiFrom(avgGain: number, avgLoss: number): number {
|
|
if (avgLoss === 0) return 100;
|
|
const rs = avgGain / avgLoss;
|
|
return 100 - 100 / (1 + rs);
|
|
}
|
|
|
|
/**
|
|
* Relative Volume: current volume divided by the SMA of the previous `period`
|
|
* volumes. Returns `undefined` before the period is complete.
|
|
*/
|
|
export function relativeVolume(volumes: number[], period: number = 20): (number | undefined)[] {
|
|
if (period <= 0) throw new Error(`period must be positive, got ${period}`);
|
|
const result: (number | undefined)[] = new Array(volumes.length);
|
|
|
|
if (volumes.length < period + 1) {
|
|
return result; // all undefined — need `period` prior values to compute the SMA, plus 1 current
|
|
}
|
|
|
|
// The first `period` values form the initial SMA; the result at index `period`
|
|
// is volumes[period] / SMA(volumes[0..period-1]).
|
|
let sum = 0;
|
|
for (let i = 0; i < period; i++) sum += volumes[i];
|
|
|
|
result[period] = volumes[period] / (sum / period);
|
|
|
|
for (let i = period + 1; i < volumes.length; i++) {
|
|
sum += volumes[i] - volumes[i - period];
|
|
result[i] = volumes[i] / (sum / period);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Extract a price series from PriceCandle[] and compute an EMA.
|
|
* Defaults to the close (`c`) field; pass `"adjClose"` to use adjusted close.
|
|
*/
|
|
export function emaFromCandles(
|
|
candles: PriceCandle[],
|
|
priceKey: "c" | "adjClose" = "c",
|
|
period: number,
|
|
): (number | undefined)[] {
|
|
const prices: number[] = new Array(candles.length);
|
|
for (let i = 0; i < candles.length; i++) {
|
|
const c = candles[i];
|
|
if (priceKey === "adjClose") {
|
|
prices[i] = c.adjClose ?? c.c; // fall back to close if adjClose is null
|
|
} else {
|
|
prices[i] = c.c;
|
|
}
|
|
}
|
|
return ema(prices, period);
|
|
}
|