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:
Investor Flow Build
2026-06-29 23:33:02 -04:00
parent a303720c35
commit 2c7e7a0786
6 changed files with 526 additions and 0 deletions
@@ -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, []);
});
});
+136
View File
@@ -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);
}