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:
@@ -6,7 +6,8 @@ import { DEFAULT_EXEC } from '../engine/portfolio';
|
|||||||
import { H4 } from '../market/aggregate';
|
import { H4 } from '../market/aggregate';
|
||||||
|
|
||||||
const M15 = 15 * 60 * 1000;
|
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
|
* Synthetische 15m-Serie: Plateau (4h-Closes ~100), dann Breakout-4h-Candle
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
|||||||
barCloseTs >= cfg.tradeFrom &&
|
barCloseTs >= cfg.tradeFrom &&
|
||||||
barCloseTs < cfg.tradeTo
|
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') {
|
if (ev.signal === 'long') {
|
||||||
const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr;
|
const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr;
|
||||||
const equity = portfolio.equity(lastClose);
|
const equity = portfolio.equity(lastClose);
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120,
|
|||||||
|
|
||||||
export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) =>
|
export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) =>
|
||||||
[2, 3, 4].flatMap((atrMultiplier) =>
|
[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
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
91
src/server/indicators/adx.test.ts
Normal file
91
src/server/indicators/adx.test.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
44
src/server/indicators/adx.ts
Normal file
44
src/server/indicators/adx.ts
Normal file
@@ -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<number>(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<number>(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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ import { expect, test } from 'bun:test';
|
|||||||
import type { Candle } from '../types';
|
import type { Candle } from '../types';
|
||||||
import { computeIndicators, evaluateAt, DEFAULT_PARAMS, type StrategyParams } from './donchian-trend';
|
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 {
|
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 };
|
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', () => {
|
test('Long-Signal bei Donchian-Breakout über Trend-EMA', () => {
|
||||||
const c4h = breakoutSeries();
|
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.signal).toBe('long');
|
||||||
expect(ev.blockedBy).toBeNull();
|
expect(ev.blockedBy).toBeNull();
|
||||||
expect(ev.donchianHigh).toBe(17);
|
expect(ev.donchianHigh).toBe(17);
|
||||||
expect(Number.isNaN(ev.atr)).toBe(false);
|
expect(Number.isNaN(ev.atr)).toBe(false);
|
||||||
|
expect(Number.isNaN(ev.adx)).toBe(false); // ADX[7]=100 (starker Trend)
|
||||||
});
|
});
|
||||||
|
|
||||||
test('blockiert unter Donchian-High', () => {
|
test('blockiert unter Donchian-High', () => {
|
||||||
const c4h = breakoutSeries();
|
const c4h = breakoutSeries();
|
||||||
c4h[c4h.length - 1].close = 16.9; // unter 17
|
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.signal).toBeNull();
|
||||||
expect(ev.blockedBy).toBe('below_donchian');
|
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));
|
s.push(c(75, 76, 74, 75, 6));
|
||||||
// Index 7: Donchian[7] = max(76,76,76) = 76, EMA5 ≈ 80, Close 77 > Donchian aber < EMA
|
// Index 7: Donchian[7] = max(76,76,76) = 76, EMA5 ≈ 80, Close 77 > Donchian aber < EMA
|
||||||
s.push(c(77, 80, 76, 77, 7));
|
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.signal).toBeNull();
|
||||||
expect(ev.blockedBy).toBe('below_trend_ema');
|
expect(ev.blockedBy).toBe('below_trend_ema');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('blockiert bei zu wenig Daten', () => {
|
test('blockiert bei zu wenig Daten', () => {
|
||||||
const c4h = breakoutSeries().slice(0, 4);
|
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');
|
expect(ev.blockedBy).toBe('insufficient_data');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DEFAULT_PARAMS entsprechen der Spec', () => {
|
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. */
|
/** 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)', () => {
|
test('Short-Signal bei Donchian-Breakdown unter Trend-EMA (allowShort=true)', () => {
|
||||||
const c4h = breakdownSeries();
|
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.signal).toBe('short');
|
||||||
expect(ev.blockedBy).toBeNull();
|
expect(ev.blockedBy).toBeNull();
|
||||||
expect(ev.donchianLow).toBe(13); // min(low[4..6]) = min(15,14,13) = 13
|
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', () => {
|
test('Gleiche Daten mit allowShort=false → kein Short, blockiert', () => {
|
||||||
const c4h = breakdownSeries();
|
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();
|
expect(ev.signal).toBeNull();
|
||||||
// close=11 <= donchianHigh (whatever it is) → below_donchian
|
// close=11 <= donchianHigh (whatever it is) → below_donchian
|
||||||
expect(ev.blockedBy).toBe('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
|
// 1 Candle: donchianLow[6] = min(low[3..5]) = 49, EMA5 ≈ 37 < 49
|
||||||
// Close 45: < donchianLow(49) aber > EMA5(≈37) → kein Short
|
// Close 45: < donchianLow(49) aber > EMA5(≈37) → kein Short
|
||||||
t.push(c(45, 50, 44, 45, 6));
|
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
|
// close(45) < donchianLow(49) aber close(45) > trendEma(≈37) → kein Short
|
||||||
expect(ev.signal).toBeNull();
|
expect(ev.signal).toBeNull();
|
||||||
expect(ev.donchianLow).toBe(49);
|
expect(ev.donchianLow).toBe(49);
|
||||||
expect(ev.trendEma).toBeLessThan(45); // EMA ist unter Close
|
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 type { Candle } from '../types';
|
||||||
import { ema } from '../indicators/ema';
|
import { ema } from '../indicators/ema';
|
||||||
import { atr } from '../indicators/atr';
|
import { atr } from '../indicators/atr';
|
||||||
|
import { adx } from '../indicators/adx';
|
||||||
import { donchianHigh, donchianLow } from '../indicators/donchian';
|
import { donchianHigh, donchianLow } from '../indicators/donchian';
|
||||||
|
|
||||||
export interface StrategyParams {
|
export interface StrategyParams {
|
||||||
@@ -8,6 +9,7 @@ export interface StrategyParams {
|
|||||||
atrPeriod: number;
|
atrPeriod: number;
|
||||||
atrMultiplier: number;
|
atrMultiplier: number;
|
||||||
trendEmaPeriod: number;
|
trendEmaPeriod: number;
|
||||||
|
adxThreshold: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PARAMS: StrategyParams = {
|
export const DEFAULT_PARAMS: StrategyParams = {
|
||||||
@@ -15,6 +17,7 @@ export const DEFAULT_PARAMS: StrategyParams = {
|
|||||||
atrPeriod: 14,
|
atrPeriod: 14,
|
||||||
atrMultiplier: 3,
|
atrMultiplier: 3,
|
||||||
trendEmaPeriod: 200,
|
trendEmaPeriod: 200,
|
||||||
|
adxThreshold: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IndicatorSet {
|
export interface IndicatorSet {
|
||||||
@@ -22,13 +25,15 @@ export interface IndicatorSet {
|
|||||||
donchianHigh: number[];
|
donchianHigh: number[];
|
||||||
donchianLow: number[];
|
donchianLow: number[];
|
||||||
atr: number[];
|
atr: number[];
|
||||||
|
adx: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Evaluation {
|
export interface Evaluation {
|
||||||
signal: 'long' | 'short' | null;
|
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;
|
close: number;
|
||||||
atr: number;
|
atr: number;
|
||||||
|
adx: number;
|
||||||
donchianHigh: number;
|
donchianHigh: number;
|
||||||
donchianLow: number;
|
donchianLow: number;
|
||||||
trendEma: number;
|
trendEma: number;
|
||||||
@@ -41,14 +46,21 @@ export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSe
|
|||||||
donchianHigh: donchianHigh(c4h, p.donchianPeriod),
|
donchianHigh: donchianHigh(c4h, p.donchianPeriod),
|
||||||
donchianLow: donchianLow(c4h, p.donchianPeriod),
|
donchianLow: donchianLow(c4h, p.donchianPeriod),
|
||||||
atr: atr(c4h, p.atrPeriod),
|
atr: atr(c4h, p.atrPeriod),
|
||||||
|
adx: adx(c4h, p.atrPeriod),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bewertet die (abgeschlossene) 4h-Candle an Index i. */
|
/** 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 close = c4h[i]?.close ?? NaN;
|
||||||
const base = {
|
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],
|
donchianLow: ind.donchianLow[i], trendEma: ind.trendEma[i],
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
@@ -57,17 +69,20 @@ export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowSho
|
|||||||
Number.isNaN(ind.trendEma[i]) ||
|
Number.isNaN(ind.trendEma[i]) ||
|
||||||
Number.isNaN(ind.donchianHigh[i]) ||
|
Number.isNaN(ind.donchianHigh[i]) ||
|
||||||
Number.isNaN(ind.donchianLow[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 };
|
return { signal: null, blockedBy: 'insufficient_data', ...base };
|
||||||
}
|
}
|
||||||
if (close > ind.donchianHigh[i] && close > ind.trendEma[i]) {
|
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 };
|
return { signal: 'long', blockedBy: null, ...base };
|
||||||
}
|
}
|
||||||
if (allowShort && close < ind.donchianLow[i] && close < ind.trendEma[i]) {
|
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 };
|
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 };
|
if (close <= ind.donchianHigh[i]) return { signal: null, blockedBy: 'below_donchian', ...base };
|
||||||
return { signal: null, blockedBy: 'below_trend_ema', ...base };
|
return { signal: null, blockedBy: 'below_trend_ema', ...base };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user