feat: 4h-Aggregation, EMA, ATR, Donchian-High (TDD)
This commit is contained in:
25
src/server/indicators/atr.test.ts
Normal file
25
src/server/indicators/atr.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import type { Candle } from '../types';
|
||||||
|
import { atr } from './atr';
|
||||||
|
|
||||||
|
function c(h: number, l: number, cl: number): Candle {
|
||||||
|
return { ts: 0, open: cl, high: h, low: l, close: cl, volume: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ATR: konstante Range ohne Gaps → ATR = Range', () => {
|
||||||
|
// High−Low = 2 überall, Close mittig → TR = 2 für alle Bars
|
||||||
|
const candles = Array.from({ length: 20 }, () => c(11, 9, 10));
|
||||||
|
const out = atr(candles, 14);
|
||||||
|
expect(out.slice(0, 13).every(Number.isNaN)).toBe(true);
|
||||||
|
expect(out[13]).toBeCloseTo(2);
|
||||||
|
expect(out[19]).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ATR: Gap wird über True Range erfasst', () => {
|
||||||
|
// 14 ruhige Bars (TR=2), dann Gap: prevClose=10, neue Bar h=21 l=20 → TR = max(1, 11, 10) = 11
|
||||||
|
const candles = Array.from({ length: 14 }, () => c(11, 9, 10));
|
||||||
|
candles.push(c(21, 20, 20.5));
|
||||||
|
const out = atr(candles, 14);
|
||||||
|
// Wilder: (2*13 + 11) / 14
|
||||||
|
expect(out[14]).toBeCloseTo((2 * 13 + 11) / 14);
|
||||||
|
});
|
||||||
22
src/server/indicators/atr.ts
Normal file
22
src/server/indicators/atr.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Candle } from '../types';
|
||||||
|
|
||||||
|
export function atr(candles: Candle[], period: number): number[] {
|
||||||
|
const out = new Array<number>(candles.length).fill(NaN);
|
||||||
|
if (candles.length < period) return out;
|
||||||
|
const tr = candles.map((c, i) =>
|
||||||
|
i === 0
|
||||||
|
? c.high - c.low
|
||||||
|
: Math.max(
|
||||||
|
c.high - c.low,
|
||||||
|
Math.abs(c.high - candles[i - 1].close),
|
||||||
|
Math.abs(c.low - candles[i - 1].close),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < period; i++) sum += tr[i];
|
||||||
|
out[period - 1] = sum / period;
|
||||||
|
for (let i = period; i < candles.length; i++) {
|
||||||
|
out[i] = (out[i - 1] * (period - 1) + tr[i]) / period;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
15
src/server/indicators/donchian.test.ts
Normal file
15
src/server/indicators/donchian.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import type { Candle } from '../types';
|
||||||
|
import { donchianHigh } from './donchian';
|
||||||
|
|
||||||
|
function c(h: number): Candle {
|
||||||
|
return { ts: 0, open: h, high: h, low: h - 1, close: h, volume: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('donchianHigh: höchstes Hoch der letzten N Candles VOR i (i exklusiv)', () => {
|
||||||
|
const candles = [c(10), c(12), c(11), c(9), c(15)];
|
||||||
|
const out = donchianHigh(candles, 3);
|
||||||
|
expect(out.slice(0, 3).every(Number.isNaN)).toBe(true);
|
||||||
|
expect(out[3]).toBe(12); // max(10,12,11)
|
||||||
|
expect(out[4]).toBe(12); // max(12,11,9) — Candle 4 selbst zählt nicht
|
||||||
|
});
|
||||||
12
src/server/indicators/donchian.ts
Normal file
12
src/server/indicators/donchian.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Candle } from '../types';
|
||||||
|
|
||||||
|
/** Höchstes Hoch der letzten `period` Candles VOR Index i (Candle i ausgeschlossen). */
|
||||||
|
export function donchianHigh(candles: Candle[], period: number): number[] {
|
||||||
|
const out = new Array<number>(candles.length).fill(NaN);
|
||||||
|
for (let i = period; i < candles.length; i++) {
|
||||||
|
let max = -Infinity;
|
||||||
|
for (let j = i - period; j < i; j++) max = Math.max(max, candles[j].high);
|
||||||
|
out[i] = max;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
17
src/server/indicators/ema.test.ts
Normal file
17
src/server/indicators/ema.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { ema } from './ema';
|
||||||
|
|
||||||
|
test('EMA: NaN vor period-1, SMA-Seed, dann rekursiv', () => {
|
||||||
|
// ema([1..8], 5): Seed bei i=4 = SMA(1..5) = 3, k = 1/3
|
||||||
|
// i=5: 6/3 + 3*2/3 = 4 ; i=6: 7/3 + 4*2/3 = 5 ; i=7: 6
|
||||||
|
const out = ema([1, 2, 3, 4, 5, 6, 7, 8], 5);
|
||||||
|
expect(out.slice(0, 4).every(Number.isNaN)).toBe(true);
|
||||||
|
expect(out[4]).toBeCloseTo(3);
|
||||||
|
expect(out[5]).toBeCloseTo(4);
|
||||||
|
expect(out[6]).toBeCloseTo(5);
|
||||||
|
expect(out[7]).toBeCloseTo(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EMA: zu wenig Daten → alles NaN', () => {
|
||||||
|
expect(ema([1, 2, 3], 5).every(Number.isNaN)).toBe(true);
|
||||||
|
});
|
||||||
12
src/server/indicators/ema.ts
Normal file
12
src/server/indicators/ema.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function ema(values: number[], period: number): number[] {
|
||||||
|
const out = new Array<number>(values.length).fill(NaN);
|
||||||
|
if (values.length < period) return out;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < period; i++) sum += values[i];
|
||||||
|
out[period - 1] = sum / period;
|
||||||
|
const k = 2 / (period + 1);
|
||||||
|
for (let i = period; i < values.length; i++) {
|
||||||
|
out[i] = values[i] * k + out[i - 1] * (1 - k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
40
src/server/market/aggregate.test.ts
Normal file
40
src/server/market/aggregate.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import type { Candle } from '../types';
|
||||||
|
import { aggregate4h, H4 } from './aggregate';
|
||||||
|
|
||||||
|
const M15 = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function c15(ts: number, o: number, h: number, l: number, cl: number, v = 1): Candle {
|
||||||
|
return { ts, open: o, high: h, low: l, close: cl, volume: v };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('aggregiert 16 15m-Candles zu einer 4h-Candle, verwirft unvollständigen letzten Bucket', () => {
|
||||||
|
const candles: Candle[] = [];
|
||||||
|
// Bucket 1: ts 0..15*M15 — Preise 100..115
|
||||||
|
for (let i = 0; i < 16; i++) candles.push(c15(i * M15, 100 + i, 101 + i, 99 + i, 100.5 + i));
|
||||||
|
// Bucket 2: nur 2 Candles (unvollständig, wird aber als "letzter" sowieso verworfen,
|
||||||
|
// sobald kein dritter Bucket folgt)
|
||||||
|
candles.push(c15(H4, 200, 210, 195, 205));
|
||||||
|
candles.push(c15(H4 + M15, 205, 220, 204, 218));
|
||||||
|
|
||||||
|
const out = aggregate4h(candles);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].ts).toBe(0);
|
||||||
|
expect(out[0].open).toBe(100);
|
||||||
|
expect(out[0].high).toBe(101 + 15);
|
||||||
|
expect(out[0].low).toBe(99);
|
||||||
|
expect(out[0].close).toBe(100.5 + 15);
|
||||||
|
expect(out[0].volume).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lücken in den Daten erzeugen keine Phantom-Buckets', () => {
|
||||||
|
const candles: Candle[] = [
|
||||||
|
c15(0, 1, 2, 0.5, 1.5),
|
||||||
|
c15(2 * H4, 3, 4, 2.5, 3.5), // Bucket dazwischen fehlt komplett
|
||||||
|
c15(2 * H4 + M15, 3.5, 5, 3, 4),
|
||||||
|
c15(3 * H4, 9, 9, 9, 9), // öffnet neuen Bucket → Bucket 2*H4 wird finalisiert
|
||||||
|
];
|
||||||
|
const out = aggregate4h(candles);
|
||||||
|
expect(out.map((c) => c.ts)).toEqual([0, 2 * H4]);
|
||||||
|
expect(out[1].high).toBe(5);
|
||||||
|
});
|
||||||
26
src/server/market/aggregate.ts
Normal file
26
src/server/market/aggregate.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Candle } from '../types';
|
||||||
|
|
||||||
|
export const H4 = 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregiert 15m-Candles (sortiert, ts aufsteigend) zu 4h-Candles.
|
||||||
|
* Der letzte Bucket wird verworfen, weil nicht feststellbar ist, ob er
|
||||||
|
* abgeschlossen ist — der Backtest-Runner arbeitet nur auf geschlossenen Candles.
|
||||||
|
*/
|
||||||
|
export function aggregate4h(c15: Candle[]): Candle[] {
|
||||||
|
const out: Candle[] = [];
|
||||||
|
let cur: Candle | null = null;
|
||||||
|
for (const c of c15) {
|
||||||
|
const bucket = Math.floor(c.ts / H4) * H4;
|
||||||
|
if (cur && cur.ts === bucket) {
|
||||||
|
cur.high = Math.max(cur.high, c.high);
|
||||||
|
cur.low = Math.min(cur.low, c.low);
|
||||||
|
cur.close = c.close;
|
||||||
|
cur.volume += c.volume;
|
||||||
|
} else {
|
||||||
|
if (cur) out.push(cur);
|
||||||
|
cur = { ts: bucket, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out; // letzter Bucket absichtlich nicht gepusht
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user