feat: 4h-Aggregation, EMA, ATR, Donchian-High (TDD)

This commit is contained in:
2026-06-09 20:18:53 +00:00
parent 568e282b07
commit c2f1221eb9
8 changed files with 169 additions and 0 deletions

View 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', () => {
// HighLow = 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);
});

View 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;
}

View 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
});

View 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;
}

View 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);
});

View 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;
}

View 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);
});

View 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
}