fix-backend-review-findings (ornith-35): oauth empty-token guard, session-secret startup assertion, OAuth email-link test
Fixes from ornith's backend code review, implemented by ornith-35 (cross-review by qwopus35b pending). 129/129 tests green.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
// Investor Flow — indicators.test.ts
|
||||
// Pure-logic tests for technical indicator functions. No HTTP, no filesystem, no DB.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { ema, rsi, relativeVolume, emaFromCandles } from '../indicators.ts';
|
||||
import type { PriceCandle } from '../../cache/CacheRepository.ts';
|
||||
|
||||
describe('ema', () => {
|
||||
it('returns undefined before period - 1, then a seeded SMA at period - 1', () => {
|
||||
const values = [1, 2, 3, 4, 5]; // period = 3
|
||||
const result = ema(values, 3);
|
||||
assert.equal(result[0], undefined);
|
||||
assert.equal(result[1], undefined);
|
||||
// SMA of [1,2,3] = 2
|
||||
assert.equal(result[2], 2);
|
||||
});
|
||||
|
||||
it('computes a known EMA value correctly', () => {
|
||||
// Known series: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
|
||||
// period = 3. SMA of [10,11,12] = 11.
|
||||
// EMA at index 3: (13 - 11) * (2/4) + 11 = 2*0.5 + 11 = 12
|
||||
// EMA at index 4: (14 - 12) * 0.5 + 12 = 13
|
||||
// EMA at index 9: hand-computed:
|
||||
const values = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
const result = ema(values, 3);
|
||||
assert.equal(result[2], 11);
|
||||
assert.equal(result[3], 12);
|
||||
assert.equal(result[4], 13);
|
||||
// Continue manually:
|
||||
// i=5: (15-13)*0.5+13 = 14
|
||||
assert.equal(result[5], 14);
|
||||
// i=6: (16-14)*0.5+14 = 15
|
||||
assert.equal(result[6], 15);
|
||||
// i=7: (17-15)*0.5+15 = 16
|
||||
assert.equal(result[7], 16);
|
||||
// i=8: (18-16)*0.5+16 = 17
|
||||
assert.equal(result[8], 17);
|
||||
// i=9: (19-17)*0.5+17 = 18
|
||||
assert.equal(result[9], 18);
|
||||
});
|
||||
|
||||
it('returns all undefined when values.length < period', () => {
|
||||
const result = ema([1, 2], 5);
|
||||
assert.equal(result.length, 2);
|
||||
for (const v of result) assert.equal(v, undefined);
|
||||
});
|
||||
|
||||
it('throws on non-positive period', () => {
|
||||
assert.throws(() => ema([1], 0));
|
||||
assert.throws(() => ema([1], -1));
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = ema([], 5);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rsi', () => {
|
||||
it('returns 100 on a strictly increasing series', () => {
|
||||
// 20 closes, each +1 from the previous. All deltas = 1.
|
||||
const closes: number[] = [];
|
||||
for (let i = 0; i < 20; i++) closes.push(100 + i);
|
||||
const result = rsi(closes, 14);
|
||||
// The last value (index 19) should be 100.
|
||||
assert.equal(result[19], 100);
|
||||
});
|
||||
|
||||
it('returns 0 on a strictly decreasing series', () => {
|
||||
const closes: number[] = [];
|
||||
for (let i = 0; i < 20; i++) closes.push(100 - i);
|
||||
const result = rsi(closes, 14);
|
||||
assert.equal(result[19], 0);
|
||||
});
|
||||
|
||||
it('returns undefined before the period-th close', () => {
|
||||
const closes = [1, 2, 3, 4, 5]; // period=14 (default)
|
||||
const result = rsi(closes);
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
assert.equal(result[i], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a value between 0 and 100 for mixed data', () => {
|
||||
// 30 closes alternating up/down.
|
||||
const closes: number[] = [];
|
||||
for (let i = 0; i < 30; i++) closes.push(100 + Math.sin(i * 0.5) * 10);
|
||||
const result = rsi(closes, 14);
|
||||
// At index 29 (last), should be a number between 0 and 100.
|
||||
const last = result[29];
|
||||
assert.ok(last !== undefined);
|
||||
assert.ok(last! >= 0 && last! <= 100, `RSI out of range: ${last}`);
|
||||
});
|
||||
|
||||
it('throws on non-positive period', () => {
|
||||
assert.throws(() => rsi([1, 2], 0));
|
||||
});
|
||||
|
||||
it('uses default period of 14', () => {
|
||||
const closes: number[] = [];
|
||||
for (let i = 0; i < 20; i++) closes.push(100 + i);
|
||||
const result = rsi(closes); // default 14
|
||||
assert.equal(result[19], 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeVolume', () => {
|
||||
it('returns a ratio > 1 when the last volume exceeds the average', () => {
|
||||
// 25 volumes: first 20 are ~100, last one is 300 (3x average).
|
||||
const volumes: number[] = [];
|
||||
for (let i = 0; i < 20; i++) volumes.push(100);
|
||||
volumes.push(300); // index 20
|
||||
const result = relativeVolume(volumes, 20);
|
||||
// SMA of first 20 = 100. Ratio at index 20 = 300/100 = 3.
|
||||
assert.equal(result[20], 3);
|
||||
});
|
||||
|
||||
it('returns a ratio < 1 when the last volume is below average', () => {
|
||||
const volumes: number[] = [];
|
||||
for (let i = 0; i < 20; i++) volumes.push(100);
|
||||
volumes.push(50); // index 20
|
||||
const result = relativeVolume(volumes, 20);
|
||||
assert.equal(result[20], 0.5);
|
||||
});
|
||||
|
||||
it('returns undefined before the period is complete', () => {
|
||||
const result = relativeVolume([1, 2, 3], 20);
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
assert.equal(result[i], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a sliding window for subsequent values', () => {
|
||||
// 25 volumes: [10,10,...,10] (20 times), then [5, 50].
|
||||
const volumes: number[] = [];
|
||||
for (let i = 0; i < 20; i++) volumes.push(10);
|
||||
volumes.push(5); // index 20: SMA=10, ratio=0.5
|
||||
volumes.push(50); // index 21: window slides, SMA = (10*19 + 5)/20 = 9.75, ratio = 50/9.75
|
||||
const result = relativeVolume(volumes, 20);
|
||||
assert.equal(result[20], 0.5);
|
||||
// At index 21: sliding window is volumes[2..21] = 18*10 + 5 + 50 = 290, SMA = 14.5
|
||||
// Wait: sum at index 20 = 200. sum += 50 - volumes[1](=10) => sum = 240. SMA = 12.
|
||||
// Ratio = 50/12 ≈ 4.1667
|
||||
const expectedRatio = 50 / 12;
|
||||
assert.ok(Math.abs(result[21]! - expectedRatio) < 1e-9, `Expected ~${expectedRatio}, got ${result[21]}`);
|
||||
});
|
||||
|
||||
it('throws on non-positive period', () => {
|
||||
assert.throws(() => relativeVolume([1], 0));
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = relativeVolume([], 5);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emaFromCandles', () => {
|
||||
it('uses close (c) by default', () => {
|
||||
const candles: PriceCandle[] = [
|
||||
{ ts: '2024-01-01', o: 10, h: 12, l: 9, c: 11, v: 100 },
|
||||
{ ts: '2024-01-02', o: 11, h: 13, l: 10, c: 12, v: 100 },
|
||||
{ ts: '2024-01-03', o: 12, h: 14, l: 11, c: 13, v: 100 },
|
||||
];
|
||||
const result = emaFromCandles(candles, 'c', 3);
|
||||
// SMA of [11, 12, 13] = 12
|
||||
assert.equal(result[2], 12);
|
||||
});
|
||||
|
||||
it('uses adjClose when requested, falling back to c if null', () => {
|
||||
const candles: PriceCandle[] = [
|
||||
{ ts: '2024-01-01', o: 10, h: 12, l: 9, c: 11, v: 100, adjClose: 10.5 },
|
||||
{ ts: '2024-01-02', o: 11, h: 13, l: 10, c: 12, v: 100, adjClose: 11.5 },
|
||||
{ ts: '2024-01-03', o: 12, h: 14, l: 11, c: 13, v: 100, adjClose: 12.5 },
|
||||
];
|
||||
const result = emaFromCandles(candles, 'adjClose', 3);
|
||||
// SMA of [10.5, 11.5, 12.5] = 11.5
|
||||
assert.equal(result[2], 11.5);
|
||||
});
|
||||
|
||||
it('falls back to c when adjClose is null', () => {
|
||||
const candles: PriceCandle[] = [
|
||||
{ ts: '2024-01-01', o: 10, h: 12, l: 9, c: 11, v: 100, adjClose: null },
|
||||
{ ts: '2024-01-02', o: 11, h: 13, l: 10, c: 12, v: 100, adjClose: null },
|
||||
{ ts: '2024-01-03', o: 12, h: 14, l: 11, c: 13, v: 100, adjClose: null },
|
||||
];
|
||||
const result = emaFromCandles(candles, 'adjClose', 3);
|
||||
// Falls back to c: SMA of [11, 12, 13] = 12
|
||||
assert.equal(result[2], 12);
|
||||
});
|
||||
|
||||
it('returns undefined before period - 1', () => {
|
||||
const candles: PriceCandle[] = [
|
||||
{ ts: '2024-01-01', o: 10, h: 12, l: 9, c: 11, v: 100 },
|
||||
{ ts: '2024-01-02', o: 11, h: 13, l: 10, c: 12, v: 100 },
|
||||
];
|
||||
const result = emaFromCandles(candles, 'c', 5);
|
||||
for (const v of result) assert.equal(v, undefined);
|
||||
});
|
||||
|
||||
it('handles empty candles array', () => {
|
||||
const result = emaFromCandles([], 'c', 5);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user