From c07a34e6719d069ef83b41481902aefa4c41879b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:46:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20ADX-Trendst=C3=A4rke-Filter=20(fix=2020?= =?UTF-8?q?,=20nicht=20im=20Grid)=20gegen=20Chop-Whipsaw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/server/backtest/runner.test.ts | 3 +- src/server/backtest/runner.ts | 2 +- src/server/backtest/walkforward.ts | 8 +- src/server/indicators/adx.test.ts | 91 ++++++++++++++++++++++ src/server/indicators/adx.ts | 44 +++++++++++ src/server/strategy/donchian-trend.test.ts | 69 +++++++++++++--- src/server/strategy/donchian-trend.ts | 25 ++++-- 7 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 src/server/indicators/adx.test.ts create mode 100644 src/server/indicators/adx.ts diff --git a/src/server/backtest/runner.test.ts b/src/server/backtest/runner.test.ts index 6b13a70..3f10d75 100644 --- a/src/server/backtest/runner.test.ts +++ b/src/server/backtest/runner.test.ts @@ -6,7 +6,8 @@ import { DEFAULT_EXEC } from '../engine/portfolio'; import { H4 } from '../market/aggregate'; const M15 = 15 * 60 * 1000; -const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5 }; +// adxThreshold: 0 — neutralisiert den ADX-Filter, damit Breakout-Tests unverändert bleiben +const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5, adxThreshold: 0 }; /** * Synthetische 15m-Serie: Plateau (4h-Closes ~100), dann Breakout-4h-Candle diff --git a/src/server/backtest/runner.ts b/src/server/backtest/runner.ts index e3275ca..71ea441 100644 --- a/src/server/backtest/runner.ts +++ b/src/server/backtest/runner.ts @@ -99,7 +99,7 @@ export function runBacktest(candles15ByPair: Map, cfg: BacktestC barCloseTs >= cfg.tradeFrom && barCloseTs < cfg.tradeTo ) { - const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.allowShort); + const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.params, cfg.allowShort); if (ev.signal === 'long') { const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; const equity = portfolio.equity(lastClose); diff --git a/src/server/backtest/walkforward.ts b/src/server/backtest/walkforward.ts index 5e0d944..2f3b5be 100644 --- a/src/server/backtest/walkforward.ts +++ b/src/server/backtest/walkforward.ts @@ -29,7 +29,13 @@ export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120, export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) => [2, 3, 4].flatMap((atrMultiplier) => - [100, 200].map((trendEmaPeriod) => ({ donchianPeriod, atrPeriod: 14, atrMultiplier, trendEmaPeriod })), + [100, 200].map((trendEmaPeriod) => ({ + donchianPeriod, + atrPeriod: 14, + atrMultiplier, + trendEmaPeriod, + adxThreshold: 20, // fix, nicht im Grid: zusätzlicher Freiheitsgrad würde das Gate aushöhlen + })), ), ); diff --git a/src/server/indicators/adx.test.ts b/src/server/indicators/adx.test.ts new file mode 100644 index 0000000..bcea35c --- /dev/null +++ b/src/server/indicators/adx.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from 'bun:test'; +import type { Candle } from '../types'; +import { adx } from './adx'; + +function c(h: number, l: number, cl: number): Candle { + return { ts: 0, open: cl, high: h, low: l, close: cl, volume: 1 }; +} + +// --- (a) NaN vor Index 2×period−1 --- + +test('ADX: NaN vor Warmup (Index < 2×period−1)', () => { + // period=3 → erste valide Stelle: Index 5 (= 2×3−1) + // Starker Aufwärtstrend: high=close, low=close−0.5, +1 pro Bar + const candles: Candle[] = []; + for (let i = 0; i < 10; i++) candles.push(c(i + 1, i + 0.5, i + 1)); + const result = adx(candles, 3); + // Alle Indizes < 5 müssen NaN sein + for (let i = 0; i < 5; i++) { + expect(Number.isNaN(result[i])).toBe(true); + } + // Index 5 muss ein valider Wert sein + expect(Number.isNaN(result[5])).toBe(false); +}); + +test('ADX: zu kurze Serie → alles NaN', () => { + // n=5 < 2×3=6 → alles NaN + const candles: Candle[] = []; + for (let i = 0; i < 5; i++) candles.push(c(i + 1, i + 0.5, i + 1)); + const result = adx(candles, 3); + expect(result.every(Number.isNaN)).toBe(true); +}); + +// --- (b) Starker Aufwärtstrend → ADX hoch (> 50) --- +// +// Serie: high = close = i+1, low = i+0.5 → stetig +1/Bar. +// Jede Bar: up = 1 (high-Diff), down = -1 (<0) → plusDM=1, minusDM=0. +// TR = max(0.5, |high−prevClose|, |low−prevClose|) = max(0.5, 1, 0.5) = 1 +// (ab Bar 1: high[i]=i+1, prevClose=i → |high−prevClose|=1) +// period=3: sTR=3, sPlus=3, sMinus=0 → DI+=100, DI−=0 → DX=100. +// Wilder-Glättung hält DX=100 (reine Aufwärtsbewegung bleibt konstant). +// ADX[5] = (DX[3]+DX[4]+DX[5])/3 = 100. Alle späteren ebenfalls 100. + +test('ADX: starker Aufwärtstrend → ADX > 50 nach Warmup', () => { + const candles: Candle[] = []; + // close = i+1, high = close, low = close−0.5 + // Achtung: Bar 0 hat kein prevClose → TR[0]=high[0]−low[0]=0.5 (wird nicht in InitSum genutzt) + for (let i = 0; i < 20; i++) candles.push(c(i + 1, i + 0.5, i + 1)); + const result = adx(candles, 3); + // Ab Index 5 (erster valider Wert) bis Ende: ADX muss > 50 sein + for (let i = 5; i < result.length; i++) { + expect(result[i]).toBeGreaterThan(50); + } +}); + +// --- (c) Völlig flache Candles → ADX = 0 (nicht NaN) --- +// +// H=L=C=100 für alle Bars. +// TR=0, plusDM=0, minusDM=0 → sTR=0 → computeDx gibt 0 zurück. +// DX=0 → ADX=0. + +test('ADX: flache Candles → ADX = 0 (nicht NaN)', () => { + const candles: Candle[] = Array.from({ length: 20 }, () => c(100, 100, 100)); + const result = adx(candles, 3); + // Indizes < 5: NaN + for (let i = 0; i < 5; i++) expect(Number.isNaN(result[i])).toBe(true); + // Ab Index 5: exakt 0 + for (let i = 5; i < result.length; i++) expect(result[i]).toBe(0); +}); + +// --- (d) Zickzack (gleich große Auf/Ab-Bewegungen) → ADX < 25 nach Warmup --- +// +// Serie: close alterniert 0,1,0,1,0,1,...; high=close+0.5, low=close−0.5. +// UpBar (close steigt): up=1, down=−1<0 → plusDM=1, minusDM=0. +// DownBar (close fällt): up=−1<0, down=1 → plusDM=0, minusDM=1. +// Kein klarer Trend → DX bleibt moderat, ADX konvergiert deutlich unter 25 +// bei ausreichend langer Serie (ab Index ~12 stabil). +// Mit 30 Candles und period=3 hat ADX 25 Schritte nach dem Warmup zum Einpendeln. + +test('ADX: Zickzack-Markt → ADX < 25 nach ausreichend Warmup', () => { + const candles: Candle[] = []; + for (let i = 0; i < 30; i++) { + const cl = i % 2 === 0 ? 0 : 1; + candles.push(c(cl + 0.5, cl - 0.5, cl)); + } + const result = adx(candles, 3); + // Prüfe späte Indizes (ab 12), damit Einpendeln abgeschlossen ist + for (let i = 12; i < result.length; i++) { + expect(Number.isNaN(result[i])).toBe(false); + expect(result[i]).toBeLessThan(25); + } +}); diff --git a/src/server/indicators/adx.ts b/src/server/indicators/adx.ts new file mode 100644 index 0000000..f8e0d30 --- /dev/null +++ b/src/server/indicators/adx.ts @@ -0,0 +1,44 @@ +import type { Candle } from '../types'; + +/** Wilder ADX: +DM/−DM → Wilder-geglättet → DI± → DX → ADX. NaN vor Index 2×period−1. */ +export function adx(candles: Candle[], period: number): number[] { + const n = candles.length; + const out = new Array(n).fill(NaN); + if (n < 2 * period) return out; + const plusDM = [0]; + const minusDM = [0]; + const tr = [candles[0].high - candles[0].low]; + for (let i = 1; i < n; i++) { + const up = candles[i].high - candles[i - 1].high; + const down = candles[i - 1].low - candles[i].low; + plusDM.push(up > down && up > 0 ? up : 0); + minusDM.push(down > up && down > 0 ? down : 0); + tr.push(Math.max( + candles[i].high - candles[i].low, + Math.abs(candles[i].high - candles[i - 1].close), + Math.abs(candles[i].low - candles[i - 1].close), + )); + } + let sTR = 0, sPlus = 0, sMinus = 0; + for (let i = 1; i <= period; i++) { sTR += tr[i]; sPlus += plusDM[i]; sMinus += minusDM[i]; } + const dx = new Array(n).fill(NaN); + const computeDx = () => { + if (sTR === 0) return 0; // völlig flacher Markt + const plusDI = (100 * sPlus) / sTR; + const minusDI = (100 * sMinus) / sTR; + const sum = plusDI + minusDI; + return sum === 0 ? 0 : (100 * Math.abs(plusDI - minusDI)) / sum; + }; + dx[period] = computeDx(); + for (let i = period + 1; i < n; i++) { + sTR = sTR - sTR / period + tr[i]; + sPlus = sPlus - sPlus / period + plusDM[i]; + sMinus = sMinus - sMinus / period + minusDM[i]; + dx[i] = computeDx(); + } + let sum = 0; + for (let i = period; i < 2 * period; i++) sum += dx[i]; + out[2 * period - 1] = sum / period; + for (let i = 2 * period; i < n; i++) out[i] = (out[i - 1] * (period - 1) + dx[i]) / period; + return out; +} diff --git a/src/server/strategy/donchian-trend.test.ts b/src/server/strategy/donchian-trend.test.ts index 4b29229..2090dd8 100644 --- a/src/server/strategy/donchian-trend.test.ts +++ b/src/server/strategy/donchian-trend.test.ts @@ -2,7 +2,8 @@ import { expect, test } from 'bun:test'; import type { Candle } from '../types'; import { computeIndicators, evaluateAt, DEFAULT_PARAMS, type StrategyParams } from './donchian-trend'; -const P: StrategyParams = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 3, trendEmaPeriod: 5 }; +// adxThreshold: 0 — neutralisiert den ADX-Filter in allen bestehenden Tests +const P: StrategyParams = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 3, trendEmaPeriod: 5, adxThreshold: 0 }; function c(o: number, h: number, l: number, cl: number, ts = 0): Candle { return { ts, open: o, high: h, low: l, close: cl, volume: 1 }; @@ -19,17 +20,18 @@ function breakoutSeries(): Candle[] { test('Long-Signal bei Donchian-Breakout über Trend-EMA', () => { const c4h = breakoutSeries(); - const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P); expect(ev.signal).toBe('long'); expect(ev.blockedBy).toBeNull(); expect(ev.donchianHigh).toBe(17); expect(Number.isNaN(ev.atr)).toBe(false); + expect(Number.isNaN(ev.adx)).toBe(false); // ADX[7]=100 (starker Trend) }); test('blockiert unter Donchian-High', () => { const c4h = breakoutSeries(); c4h[c4h.length - 1].close = 16.9; // unter 17 - const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P); expect(ev.signal).toBeNull(); expect(ev.blockedBy).toBe('below_donchian'); }); @@ -46,19 +48,19 @@ test('blockiert unter Trend-EMA (Bärenmarkt-Filter)', () => { s.push(c(75, 76, 74, 75, 6)); // Index 7: Donchian[7] = max(76,76,76) = 76, EMA5 ≈ 80, Close 77 > Donchian aber < EMA s.push(c(77, 80, 76, 77, 7)); - const ev = evaluateAt(s, computeIndicators(s, P), s.length - 1); + const ev = evaluateAt(s, computeIndicators(s, P), s.length - 1, P); expect(ev.signal).toBeNull(); expect(ev.blockedBy).toBe('below_trend_ema'); }); test('blockiert bei zu wenig Daten', () => { const c4h = breakoutSeries().slice(0, 4); - const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P); expect(ev.blockedBy).toBe('insufficient_data'); }); test('DEFAULT_PARAMS entsprechen der Spec', () => { - expect(DEFAULT_PARAMS).toEqual({ donchianPeriod: 20, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 200 }); + expect(DEFAULT_PARAMS).toEqual({ donchianPeriod: 20, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 200, adxThreshold: 20 }); }); /** Abwärtstrend, letzte Candle bricht unter das 3er-Tief aus. */ @@ -73,7 +75,7 @@ function breakdownSeries(): Candle[] { test('Short-Signal bei Donchian-Breakdown unter Trend-EMA (allowShort=true)', () => { const c4h = breakdownSeries(); - const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, true); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P, true); expect(ev.signal).toBe('short'); expect(ev.blockedBy).toBeNull(); expect(ev.donchianLow).toBe(13); // min(low[4..6]) = min(15,14,13) = 13 @@ -81,7 +83,7 @@ test('Short-Signal bei Donchian-Breakdown unter Trend-EMA (allowShort=true)', () test('Gleiche Daten mit allowShort=false → kein Short, blockiert', () => { const c4h = breakdownSeries(); - const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, false); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P, false); expect(ev.signal).toBeNull(); // close=11 <= donchianHigh (whatever it is) → below_donchian expect(ev.blockedBy).toBe('below_donchian'); @@ -122,9 +124,58 @@ test('Breakdown aber Close > EMA → kein Short (Aufwärtstrend-Filter)', () => // 1 Candle: donchianLow[6] = min(low[3..5]) = 49, EMA5 ≈ 37 < 49 // Close 45: < donchianLow(49) aber > EMA5(≈37) → kein Short t.push(c(45, 50, 44, 45, 6)); - const ev = evaluateAt(t, computeIndicators(t, P), t.length - 1, true); + const ev = evaluateAt(t, computeIndicators(t, P), t.length - 1, P, true); // close(45) < donchianLow(49) aber close(45) > trendEma(≈37) → kein Short expect(ev.signal).toBeNull(); expect(ev.donchianLow).toBe(49); expect(ev.trendEma).toBeLessThan(45); // EMA ist unter Close }); + +// --- ADX-Filter-Tests --- +// +// Serie: 27 Zickzack-Candles (close ∈ {0,1}, high=close+0.5, low=close−0.5) +// + 1 Breakout-Candle (index 27, close=2). +// Params: atrPeriod=14 → ADX-Period=14 → erster valider ADX-Wert an Index 2×14−1=27. +// ADX[27]=4.07 (deutlich unter 20): Zickzack-Historie hat keinen klaren Trend. +// donchianPeriod=3 → donchianHigh[27]=max(high[24..26])=max(0.5,1.5,0.5)=1.5. +// close=2 > donchianHigh=1.5 ✓ und close=2 > EMA5≈0.93 ✓ → Breakout vorhanden, +// aber ADX=4.07 < threshold=20 → blockedBy='weak_trend'. + +function adxFilterSeries(): Candle[] { + const s: Candle[] = []; + // 27 Zickzack-Candles → ADX settled ~4 an Index 27 (dominiert von keiner Richtung) + // c(open, high, low, close, ts) + for (let i = 0; i < 27; i++) { + const cl = i % 2 === 0 ? 0 : 1; + s.push(c(cl, cl + 0.5, cl - 0.5, cl, i)); + } + // Breakout-Candle: close=2 > donchianHigh(1.5) und > EMA5(0.93) + s.push(c(2, 2.5, 1.5, 2, 27)); + return s; +} + +// Hilfsfunktion mit dem passenden Param-Satz für ADX-Filter-Tests +const P_ADX: StrategyParams = { donchianPeriod: 3, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 5, adxThreshold: 20 }; + +test('ADX unter Schwelle → Breakout blockiert (weak_trend)', () => { + // ADX[27]≈4.07 < adxThreshold=20 → kein Long-Signal trotz Donchian-Breakout + const c4h = adxFilterSeries(); + const ind = computeIndicators(c4h, P_ADX); + const ev = evaluateAt(c4h, ind, c4h.length - 1, P_ADX); + // Sanity: Breakout-Bedingungen sind erfüllt (ohne ADX-Filter wäre es 'long') + expect(ev.close).toBeGreaterThan(ev.donchianHigh); + expect(ev.close).toBeGreaterThan(ev.trendEma); + expect(ev.adx).toBeLessThan(20); // ADX≈4.07 + expect(ev.signal).toBeNull(); + expect(ev.blockedBy).toBe('weak_trend'); +}); + +test('ADX-Filter deaktiviert (threshold=0) → gleicher Breakout ergibt Long-Signal', () => { + // Gleiche Serie, adxThreshold=0 → ADX-Filter immer passiert → 'long' + const c4h = adxFilterSeries(); + const P_ZERO: StrategyParams = { ...P_ADX, adxThreshold: 0 }; + const ind = computeIndicators(c4h, P_ZERO); + const ev = evaluateAt(c4h, ind, c4h.length - 1, P_ZERO); + expect(ev.signal).toBe('long'); + expect(ev.blockedBy).toBeNull(); +}); diff --git a/src/server/strategy/donchian-trend.ts b/src/server/strategy/donchian-trend.ts index 47faca5..5b60a50 100644 --- a/src/server/strategy/donchian-trend.ts +++ b/src/server/strategy/donchian-trend.ts @@ -1,6 +1,7 @@ import type { Candle } from '../types'; import { ema } from '../indicators/ema'; import { atr } from '../indicators/atr'; +import { adx } from '../indicators/adx'; import { donchianHigh, donchianLow } from '../indicators/donchian'; export interface StrategyParams { @@ -8,6 +9,7 @@ export interface StrategyParams { atrPeriod: number; atrMultiplier: number; trendEmaPeriod: number; + adxThreshold: number; } export const DEFAULT_PARAMS: StrategyParams = { @@ -15,6 +17,7 @@ export const DEFAULT_PARAMS: StrategyParams = { atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 200, + adxThreshold: 20, }; export interface IndicatorSet { @@ -22,13 +25,15 @@ export interface IndicatorSet { donchianHigh: number[]; donchianLow: number[]; atr: number[]; + adx: number[]; } export interface Evaluation { signal: 'long' | 'short' | null; - blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null; + blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | 'weak_trend' | null; close: number; atr: number; + adx: number; donchianHigh: number; donchianLow: number; trendEma: number; @@ -41,14 +46,21 @@ export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSe donchianHigh: donchianHigh(c4h, p.donchianPeriod), donchianLow: donchianLow(c4h, p.donchianPeriod), atr: atr(c4h, p.atrPeriod), + adx: adx(c4h, p.atrPeriod), }; } /** Bewertet die (abgeschlossene) 4h-Candle an Index i. */ -export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowShort = false): Evaluation { +export function evaluateAt( + c4h: Candle[], + ind: IndicatorSet, + i: number, + p: { adxThreshold: number }, + allowShort = false, +): Evaluation { const close = c4h[i]?.close ?? NaN; const base = { - close, atr: ind.atr[i], donchianHigh: ind.donchianHigh[i], + close, atr: ind.atr[i], adx: ind.adx[i], donchianHigh: ind.donchianHigh[i], donchianLow: ind.donchianLow[i], trendEma: ind.trendEma[i], }; if ( @@ -57,17 +69,20 @@ export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowSho Number.isNaN(ind.trendEma[i]) || Number.isNaN(ind.donchianHigh[i]) || Number.isNaN(ind.donchianLow[i]) || - Number.isNaN(ind.atr[i]) + Number.isNaN(ind.atr[i]) || + Number.isNaN(ind.adx[i]) ) { return { signal: null, blockedBy: 'insufficient_data', ...base }; } if (close > ind.donchianHigh[i] && close > ind.trendEma[i]) { + if (ind.adx[i] < p.adxThreshold) return { signal: null, blockedBy: 'weak_trend', ...base }; return { signal: 'long', blockedBy: null, ...base }; } if (allowShort && close < ind.donchianLow[i] && close < ind.trendEma[i]) { + if (ind.adx[i] < p.adxThreshold) return { signal: null, blockedBy: 'weak_trend', ...base }; return { signal: 'short', blockedBy: null, ...base }; } - // Blocked reasons from the long perspective + // Blocked reasons from the long perspective (no breakout — ADX irrelevant here) if (close <= ind.donchianHigh[i]) return { signal: null, blockedBy: 'below_donchian', ...base }; return { signal: null, blockedBy: 'below_trend_ema', ...base }; }