From 8a5a6f4eb12969b2cde1e2357006fad64d1d6f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:22:14 +0000 Subject: [PATCH] feat: Donchian-Trend-Strategie (Entry-Evaluation) --- src/server/strategy/donchian-trend.test.ts | 62 ++++++++++++++++++++++ src/server/strategy/donchian-trend.ts | 59 ++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/server/strategy/donchian-trend.test.ts create mode 100644 src/server/strategy/donchian-trend.ts diff --git a/src/server/strategy/donchian-trend.test.ts b/src/server/strategy/donchian-trend.test.ts new file mode 100644 index 0000000..b37ca81 --- /dev/null +++ b/src/server/strategy/donchian-trend.test.ts @@ -0,0 +1,62 @@ +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 }; + +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 }; +} + +/** Aufwärtstrend, letzte Candle bricht über das 3er-Hoch aus. */ +function breakoutSeries(): Candle[] { + const s: Candle[] = []; + for (let i = 0; i < 7; i++) s.push(c(10 + i, 11 + i, 9 + i, 10.5 + i, i)); + // bisheriges 3er-Hoch: max(high[4..6]) = 17 → Close 18 bricht aus, weit über EMA5 + s.push(c(17, 18.5, 16.5, 18, 7)); + return s; +} + +test('Long-Signal bei Donchian-Breakout über Trend-EMA', () => { + const c4h = breakoutSeries(); + 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); +}); + +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, P); + expect(ev.signal).toBeNull(); + expect(ev.blockedBy).toBe('below_donchian'); +}); + +test('blockiert unter Trend-EMA (Bärenmarkt-Filter)', () => { + // Hoher Trend, dann Stufen-Abfall, dann Mini-Breakout: Close > Donchian aber < EMA + const s: Candle[] = []; + s.push(c(100, 101, 99, 100, 0)); + s.push(c(100, 101, 99, 100, 1)); + s.push(c(100, 101, 99, 100, 2)); + s.push(c(75, 76, 74, 75, 3)); + s.push(c(75, 76, 74, 75, 4)); + s.push(c(75, 76, 74, 75, 5)); + 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, 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, 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 }); +}); diff --git a/src/server/strategy/donchian-trend.ts b/src/server/strategy/donchian-trend.ts new file mode 100644 index 0000000..c87fdf0 --- /dev/null +++ b/src/server/strategy/donchian-trend.ts @@ -0,0 +1,59 @@ +import type { Candle } from '../types'; +import { ema } from '../indicators/ema'; +import { atr } from '../indicators/atr'; +import { donchianHigh } from '../indicators/donchian'; + +export interface StrategyParams { + donchianPeriod: number; + atrPeriod: number; + atrMultiplier: number; + trendEmaPeriod: number; +} + +export const DEFAULT_PARAMS: StrategyParams = { + donchianPeriod: 20, + atrPeriod: 14, + atrMultiplier: 3, + trendEmaPeriod: 200, +}; + +export interface IndicatorSet { + trendEma: number[]; + donchianHigh: number[]; + atr: number[]; +} + +export interface Evaluation { + signal: 'long' | null; + blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null; + close: number; + atr: number; + donchianHigh: number; + trendEma: number; +} + +/** Indikatoren einmal über die ganze Serie — Index i nutzt nur Daten ≤ i (kein Lookahead). */ +export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSet { + return { + trendEma: ema(c4h.map((c) => c.close), p.trendEmaPeriod), + donchianHigh: donchianHigh(c4h, p.donchianPeriod), + atr: atr(c4h, p.atrPeriod), + }; +} + +/** Bewertet die (abgeschlossene) 4h-Candle an Index i. */ +export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, p: StrategyParams): Evaluation { + const close = c4h[i]?.close ?? NaN; + const base = { close, atr: ind.atr[i], donchianHigh: ind.donchianHigh[i], trendEma: ind.trendEma[i] }; + if ( + i < 0 || + Number.isNaN(ind.trendEma[i]) || + Number.isNaN(ind.donchianHigh[i]) || + Number.isNaN(ind.atr[i]) + ) { + return { signal: null, blockedBy: 'insufficient_data', ...base }; + } + if (close <= ind.donchianHigh[i]) return { signal: null, blockedBy: 'below_donchian', ...base }; + if (close <= ind.trendEma[i]) return { signal: null, blockedBy: 'below_trend_ema', ...base }; + return { signal: 'long', blockedBy: null, ...base }; +}