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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user