feat: ADX-Trendstärke-Filter (fix 20, nicht im Grid) gegen Chop-Whipsaw

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:46:57 +00:00
parent 736db184ab
commit c07a34e671
7 changed files with 225 additions and 17 deletions

View File

@@ -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=close0.5)
// + 1 Breakout-Candle (index 27, close=2).
// Params: atrPeriod=14 → ADX-Period=14 → erster valider ADX-Wert an Index 2×141=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();
});

View File

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