From c2f1221eb9567d5d8a76e8677f3f1154a73786f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:18:53 +0000 Subject: [PATCH] feat: 4h-Aggregation, EMA, ATR, Donchian-High (TDD) --- src/server/indicators/atr.test.ts | 25 ++++++++++++++++ src/server/indicators/atr.ts | 22 ++++++++++++++ src/server/indicators/donchian.test.ts | 15 ++++++++++ src/server/indicators/donchian.ts | 12 ++++++++ src/server/indicators/ema.test.ts | 17 +++++++++++ src/server/indicators/ema.ts | 12 ++++++++ src/server/market/aggregate.test.ts | 40 ++++++++++++++++++++++++++ src/server/market/aggregate.ts | 26 +++++++++++++++++ 8 files changed, 169 insertions(+) create mode 100644 src/server/indicators/atr.test.ts create mode 100644 src/server/indicators/atr.ts create mode 100644 src/server/indicators/donchian.test.ts create mode 100644 src/server/indicators/donchian.ts create mode 100644 src/server/indicators/ema.test.ts create mode 100644 src/server/indicators/ema.ts create mode 100644 src/server/market/aggregate.test.ts create mode 100644 src/server/market/aggregate.ts diff --git a/src/server/indicators/atr.test.ts b/src/server/indicators/atr.test.ts new file mode 100644 index 0000000..4f7d001 --- /dev/null +++ b/src/server/indicators/atr.test.ts @@ -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); +}); diff --git a/src/server/indicators/atr.ts b/src/server/indicators/atr.ts new file mode 100644 index 0000000..d1d3b83 --- /dev/null +++ b/src/server/indicators/atr.ts @@ -0,0 +1,22 @@ +import type { Candle } from '../types'; + +export function atr(candles: Candle[], period: number): number[] { + const out = new Array(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; +} diff --git a/src/server/indicators/donchian.test.ts b/src/server/indicators/donchian.test.ts new file mode 100644 index 0000000..8b96236 --- /dev/null +++ b/src/server/indicators/donchian.test.ts @@ -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 +}); diff --git a/src/server/indicators/donchian.ts b/src/server/indicators/donchian.ts new file mode 100644 index 0000000..72423ad --- /dev/null +++ b/src/server/indicators/donchian.ts @@ -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(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; +} diff --git a/src/server/indicators/ema.test.ts b/src/server/indicators/ema.test.ts new file mode 100644 index 0000000..6eede33 --- /dev/null +++ b/src/server/indicators/ema.test.ts @@ -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); +}); diff --git a/src/server/indicators/ema.ts b/src/server/indicators/ema.ts new file mode 100644 index 0000000..acc7c9f --- /dev/null +++ b/src/server/indicators/ema.ts @@ -0,0 +1,12 @@ +export function ema(values: number[], period: number): number[] { + const out = new Array(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; +} diff --git a/src/server/market/aggregate.test.ts b/src/server/market/aggregate.test.ts new file mode 100644 index 0000000..5fbef6d --- /dev/null +++ b/src/server/market/aggregate.test.ts @@ -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); +}); diff --git a/src/server/market/aggregate.ts b/src/server/market/aggregate.ts new file mode 100644 index 0000000..b5eaeb5 --- /dev/null +++ b/src/server/market/aggregate.ts @@ -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 +}