From 0e1b477e27654b4cbcbb0ef659f3d64ada33b05c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:29:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Short-Seite=20=E2=80=94=20Indikator,=20?= =?UTF-8?q?Strategie,=20Chandelier,=20Sizing,=20Portfolio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - donchianLow: Spiegelbild von donchianHigh (tiefstes Tief der N Candles vor i) - evaluateAt(allowShort=false): Short-Signal wenn close < donchianLow && close < trendEma - IndicatorSet + Evaluation erweitert um donchianLow; signal: 'long'|'short'|null - updateChandelierShort: ll + mult×ATR, wandert nur abwärts - sizePosition(side): Short ignoriert Cash-Cap, stopDist = stop − entry - Portfolio: highestHigh → trailExtreme; open/close/equity für Shorts; side auf Position + ClosedTrade Co-Authored-By: Claude Fable 5 --- src/server/backtest/metrics.test.ts | 2 +- src/server/engine/portfolio.test.ts | 45 +++++++++++++ src/server/engine/portfolio.ts | 74 ++++++++++++++++------ src/server/engine/sizing.test.ts | 26 ++++++++ src/server/engine/sizing.ts | 14 +++- src/server/indicators/donchian.test.ts | 11 +++- src/server/indicators/donchian.ts | 11 ++++ src/server/strategy/chandelier.test.ts | 31 ++++++++- src/server/strategy/chandelier.ts | 12 ++++ src/server/strategy/donchian-trend.test.ts | 68 ++++++++++++++++++++ src/server/strategy/donchian-trend.ts | 25 ++++++-- 11 files changed, 289 insertions(+), 30 deletions(-) diff --git a/src/server/backtest/metrics.test.ts b/src/server/backtest/metrics.test.ts index af1d2a1..36f6680 100644 --- a/src/server/backtest/metrics.test.ts +++ b/src/server/backtest/metrics.test.ts @@ -3,7 +3,7 @@ import { computeMetrics } from './metrics'; import type { ClosedTrade } from '../engine/portfolio'; function t(pnl: number): ClosedTrade { - return { pair: 'BTC_USDT', entryTs: 0, entryPrice: 1, exitTs: 1, exitPrice: 1, qty: 1, pnl, r: pnl / 10, exitReason: 'trailing_stop' }; + return { pair: 'BTC_USDT', entryTs: 0, entryPrice: 1, exitTs: 1, exitPrice: 1, qty: 1, pnl, r: pnl / 10, exitReason: 'trailing_stop', side: 'long' }; } test('ProfitFactor, WinRate, AvgR', () => { diff --git a/src/server/engine/portfolio.test.ts b/src/server/engine/portfolio.test.ts index 2058bf3..330144c 100644 --- a/src/server/engine/portfolio.test.ts +++ b/src/server/engine/portfolio.test.ts @@ -38,3 +38,48 @@ test('close ohne Position wirft', () => { const p = new Portfolio(1000, DEFAULT_EXEC); expect(() => p.close('BTC_USDT', 0, 100, 'trailing_stop')).toThrow(); }); + +// --- Short-Tests --- + +test('Short Entry: Cash steigt um Netto-Erlös', () => { + // signalPrice=100, fill=100*(1−0.0005)=99.95, proceeds=1*99.95=99.95 + // fee=99.95*0.001=0.09995, cash += 99.95−0.09995 = 99.85005 + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 106, 1, 10, 'short'); + const pos = p.positions.get('BTC_USDT')!; + expect(pos.entryPrice).toBeCloseTo(99.95); // 100 * (1 − 0.0005) + expect(pos.entryCost).toBeCloseTo(99.85005); // Netto-Erlös + expect(p.cash).toBeCloseTo(1000 + 99.85005); // Cash steigt + expect(pos.side).toBe('short'); +}); + +test('Short Exit: PnL positiv bei Preisrückgang, R-Multiple korrekt', () => { + // Entry: signalPrice=100, fill=99.95, entryCost=99.85005 + // Exit bei 90: fill=90*(1+0.0005)=90.045, cost=1*90.045=90.045 + // fee=90.045*0.001=0.090045, cash -= 90.045+0.090045=90.135045 + // pnl = entryCost − (cost+fee) = 99.85005 − 90.135045 = 9.715005 + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 106, 1, 10, 'short'); + const trade = p.close('BTC_USDT', 1, 90, 'trailing_stop'); + const entryCost = 99.95 - 99.95 * 0.001; // 99.85005 + const exitCost = 90.045 + 90.045 * 0.001; // 90.135045 + const expectedPnl = entryCost - exitCost; // 9.715005 + expect(trade.pnl).toBeCloseTo(expectedPnl); + expect(trade.r).toBeCloseTo(expectedPnl / 10); + expect(trade.r).toBeGreaterThan(0); // profitabler Short → positives R + expect(trade.side).toBe('short'); + expect(p.positions.size).toBe(0); +}); + +test('Short Equity: Preisrückgang erhöht Equity', () => { + // Öffne Short bei 100; wenn Preis auf 90 fällt → equity steigt + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 106, 1, 10, 'short'); + const eqAt90 = p.equity(new Map([['BTC_USDT', 90]])); + const eqAt110 = p.equity(new Map([['BTC_USDT', 110]])); + // equity = cash − qty*lastPrice (proceeds sind in cash) + // Bei 90: cash(1099.85005) − 1*90 = 1009.85005 + // Bei 110: cash(1099.85005) − 1*110 = 989.85005 + expect(eqAt90).toBeGreaterThan(eqAt110); + expect(eqAt90).toBeCloseTo(p.cash - 1 * 90); +}); diff --git a/src/server/engine/portfolio.ts b/src/server/engine/portfolio.ts index 554bbf4..a6452f0 100644 --- a/src/server/engine/portfolio.ts +++ b/src/server/engine/portfolio.ts @@ -12,11 +12,14 @@ export interface Position { qty: number; entryTs: number; entryPrice: number; // Fill inkl. Slippage - entryCost: number; // qty*fill + Entry-Fee + /** Long: Kosten (qty×fill + Entry-Fee); Short: Netto-Erlös (proceeds − Entry-Fee). */ + entryCost: number; initialStop: number; stop: number; - highestHigh: number; + /** Long: höchstes Hoch seit Entry; Short: tiefstes Tief seit Entry. */ + trailExtreme: number; riskAmount: number; + side: 'long' | 'short'; } export interface ClosedTrade { @@ -29,6 +32,7 @@ export interface ClosedTrade { pnl: number; r: number; exitReason: 'trailing_stop' | 'end_of_data'; + side: 'long' | 'short'; } export class Portfolio { @@ -40,29 +44,57 @@ export class Portfolio { this.cash = startCapital; } - open(pair: Pair, ts: number, signalPrice: number, initialStop: number, qty: number, riskAmount: number): void { + open(pair: Pair, ts: number, signalPrice: number, initialStop: number, qty: number, riskAmount: number, side: 'long' | 'short' = 'long'): void { if (this.positions.has(pair)) throw new Error(`open auf bestehende Position: ${pair}`); - const fill = signalPrice * (1 + this.exec.slippage); - const cost = qty * fill; - const fee = cost * this.exec.feeRate; - this.cash -= cost + fee; - this.positions.set(pair, { - pair, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee, - initialStop, stop: initialStop, highestHigh: signalPrice, riskAmount, // Markt-Preis, nicht Fill: der Chandelier trailt Markt-Hochs, Slippage ist Ausführungsartefakt - }); + if (side === 'short') { + const fill = signalPrice * (1 - this.exec.slippage); + const proceeds = qty * fill; + const fee = proceeds * this.exec.feeRate; + this.cash += proceeds - fee; + this.positions.set(pair, { + pair, qty, entryTs: ts, entryPrice: fill, + entryCost: proceeds - fee, // Netto-Erlös des Leerverkaufs + initialStop, stop: initialStop, + trailExtreme: signalPrice, // Markt-Preis, nicht Fill + riskAmount, side, + }); + } else { + const fill = signalPrice * (1 + this.exec.slippage); + const cost = qty * fill; + const fee = cost * this.exec.feeRate; + this.cash -= cost + fee; + this.positions.set(pair, { + pair, qty, entryTs: ts, entryPrice: fill, + entryCost: cost + fee, // Kosten des Kaufs + initialStop, stop: initialStop, + trailExtreme: signalPrice, // Markt-Preis, nicht Fill: der Chandelier trailt Markt-Hochs, Slippage ist Ausführungsartefakt + riskAmount, side, + }); + } } close(pair: Pair, ts: number, exitPrice: number, exitReason: ClosedTrade['exitReason']): ClosedTrade { const pos = this.positions.get(pair); if (!pos) throw new Error(`close ohne Position: ${pair}`); - const fill = exitPrice * (1 - this.exec.slippage); - const proceeds = pos.qty * fill; - const fee = proceeds * this.exec.feeRate; - this.cash += proceeds - fee; - const pnl = proceeds - fee - pos.entryCost; + let pnl: number; + let fill: number; + if (pos.side === 'short') { + // Deckungskauf: schlechterer Fill = höherer Preis + fill = exitPrice * (1 + this.exec.slippage); + const cost = pos.qty * fill; + const fee = cost * this.exec.feeRate; + this.cash -= cost + fee; + pnl = pos.entryCost - (cost + fee); + } else { + fill = exitPrice * (1 - this.exec.slippage); + const proceeds = pos.qty * fill; + const fee = proceeds * this.exec.feeRate; + this.cash += proceeds - fee; + pnl = proceeds - fee - pos.entryCost; + } const trade: ClosedTrade = { pair, entryTs: pos.entryTs, entryPrice: pos.entryPrice, exitTs: ts, - exitPrice: fill, qty: pos.qty, pnl, r: pnl / pos.riskAmount, exitReason, + exitPrice: fill, qty: pos.qty, pnl, r: pnl / pos.riskAmount, exitReason, side: pos.side, }; this.trades.push(trade); this.positions.delete(pair); @@ -72,7 +104,13 @@ export class Portfolio { equity(lastClose: Map): number { let eq = this.cash; for (const pos of this.positions.values()) { - eq += pos.qty * (lastClose.get(pos.pair) ?? pos.entryPrice); + const last = lastClose.get(pos.pair) ?? pos.entryPrice; + if (pos.side === 'short') { + // Proceeds already in cash; owe qty×last to close + eq -= pos.qty * last; + } else { + eq += pos.qty * last; + } } return eq; } diff --git a/src/server/engine/sizing.test.ts b/src/server/engine/sizing.test.ts index d9f02e8..4df521a 100644 --- a/src/server/engine/sizing.test.ts +++ b/src/server/engine/sizing.test.ts @@ -30,3 +30,29 @@ test('blockiert bei Stop >= Entry (invalid_stop)', () => { expect(sizePosition(1000, 1000, 100, 100, DEFAULT_RISK).blockedBy).toBe('invalid_stop'); expect(sizePosition(1000, 1000, 100, 101, DEFAULT_RISK).blockedBy).toBe('invalid_stop'); }); + +// --- Short-Sizing-Tests --- + +test('Short: 1% Equity-Risiko bestimmt die Größe', () => { + // Equity 1000, Risiko 10 USDT; Entry 100, Stop 106 → stopDist 6 → qty 10/6 + const r = sizePosition(1000, 1000, 100, 106, DEFAULT_RISK, 'short'); + expect(r.qty).toBeCloseTo(10 / 6); + expect(r.notional).toBeCloseTo((10 / 6) * 100); + expect(r.blockedBy).toBeNull(); +}); + +test('Short: invalid_stop wenn Stop <= Entry', () => { + // Stop muss ÜBER Entry liegen für Shorts + expect(sizePosition(1000, 1000, 100, 100, DEFAULT_RISK, 'short').blockedBy).toBe('invalid_stop'); + expect(sizePosition(1000, 1000, 100, 99, DEFAULT_RISK, 'short').blockedBy).toBe('invalid_stop'); +}); + +test('Short: kein Cash-Cap (Leerverkauf bringt Cash)', () => { + // Cash nur 50, aber Short ignoriert Cash-Cap → nur Equity-Cap (300) greift + const r = sizePosition(1000, 50, 100, 100.5, DEFAULT_RISK, 'short'); + // Ungecappt: riskAmount=10, stopDist=0.5 → notional=2000 → cap 300 (Equity-Cap) + expect(r.notional).toBeCloseTo(300); + // Zum Vergleich: Long mit Cash=50 wäre auf ~50 gecappt + const rLong = sizePosition(1000, 50, 100, 99.5, DEFAULT_RISK, 'long'); + expect(rLong.notional).toBeLessThanOrEqual(50); +}); diff --git a/src/server/engine/sizing.ts b/src/server/engine/sizing.ts index f063fbd..d14d89a 100644 --- a/src/server/engine/sizing.ts +++ b/src/server/engine/sizing.ts @@ -19,13 +19,21 @@ export function sizePosition( entryPrice: number, stopPrice: number, cfg: RiskConfig, + side: 'long' | 'short' = 'long', ): SizingResult { const riskAmount = equity * cfg.riskPerTradePct; - const stopDist = entryPrice - stopPrice; + // Long: stop must be below entry (stopDist = entry − stop > 0) + // Short: stop must be above entry (stopDist = stop − entry > 0) + const stopDist = side === 'short' ? stopPrice - entryPrice : entryPrice - stopPrice; if (stopDist <= 0) return { qty: 0, notional: 0, riskAmount, blockedBy: 'invalid_stop' }; let notional = (riskAmount / stopDist) * entryPrice; - // 0.997: Puffer für Fee (0.1%) + Slippage (0.05%) auf der Entry-Seite - notional = Math.min(notional, equity * cfg.maxPositionPct, cash * 0.997); + if (side === 'short') { + // Short-Verkauf bringt Cash herein → kein Cash-Cap; nur Equity-Cap + notional = Math.min(notional, equity * cfg.maxPositionPct); + } else { + // 0.997: Puffer für Fee (0.1%) + Slippage (0.05%) auf der Entry-Seite + notional = Math.min(notional, equity * cfg.maxPositionPct, cash * 0.997); + } if (!(notional >= cfg.minNotionalUsdt)) return { qty: 0, notional: 0, riskAmount, blockedBy: 'min_notional' }; return { qty: notional / entryPrice, notional, riskAmount, blockedBy: null }; } diff --git a/src/server/indicators/donchian.test.ts b/src/server/indicators/donchian.test.ts index 8b96236..a7bb575 100644 --- a/src/server/indicators/donchian.test.ts +++ b/src/server/indicators/donchian.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test'; import type { Candle } from '../types'; -import { donchianHigh } from './donchian'; +import { donchianHigh, donchianLow } from './donchian'; function c(h: number): Candle { return { ts: 0, open: h, high: h, low: h - 1, close: h, volume: 1 }; @@ -13,3 +13,12 @@ test('donchianHigh: höchstes Hoch der letzten N Candles VOR i (i exklusiv)', () expect(out[3]).toBe(12); // max(10,12,11) expect(out[4]).toBe(12); // max(12,11,9) — Candle 4 selbst zählt nicht }); + +test('donchianLow: tiefstes Tief der letzten N Candles VOR i (i exklusiv)', () => { + // c(h) erzeugt low = h-1, daher: c(10)→low=9, c(12)→low=11, c(11)→low=10, c(9)→low=8, c(15)→low=14 + const candles = [c(10), c(12), c(11), c(9), c(15)]; + const out = donchianLow(candles, 3); + expect(out.slice(0, 3).every(Number.isNaN)).toBe(true); + expect(out[3]).toBe(9); // min(9,11,10) — lows von c(10),c(12),c(11) + expect(out[4]).toBe(8); // min(11,10,8) — lows von c(12),c(11),c(9); Candle 4 selbst zählt nicht +}); diff --git a/src/server/indicators/donchian.ts b/src/server/indicators/donchian.ts index 72423ad..1d98a03 100644 --- a/src/server/indicators/donchian.ts +++ b/src/server/indicators/donchian.ts @@ -10,3 +10,14 @@ export function donchianHigh(candles: Candle[], period: number): number[] { } return out; } + +/** Tiefstes Tief der letzten `period` Candles VOR Index i (Candle i ausgeschlossen). */ +export function donchianLow(candles: Candle[], period: number): number[] { + const out = new Array(candles.length).fill(NaN); + for (let i = period; i < candles.length; i++) { + let min = Infinity; + for (let j = i - period; j < i; j++) min = Math.min(min, candles[j].low); + out[i] = min; + } + return out; +} diff --git a/src/server/strategy/chandelier.test.ts b/src/server/strategy/chandelier.test.ts index 34d7d85..a714e62 100644 --- a/src/server/strategy/chandelier.test.ts +++ b/src/server/strategy/chandelier.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { updateChandelier } from './chandelier'; +import { updateChandelier, updateChandelierShort } from './chandelier'; test('Stop steigt mit neuem Hoch', () => { // hh 100 → 110, ATR 2, Mult 3 → Stop 110−6 = 104 @@ -27,3 +27,32 @@ test('Stop ratchtet hoch wenn ATR schrumpft, auch ohne neues Hoch', () => { expect(r.highestHigh).toBe(110); expect(r.stop).toBe(107); }); + +// --- Short-Chandelier-Tests (Spiegelbild) --- + +test('Short-Stop fällt mit neuem Tief', () => { + // ll 100 → 90, ATR 2, Mult 3 → Stop 90+6 = 96 + const r = updateChandelierShort({ lowestLow: 100, stop: 106 }, 90, 2, 3); + expect(r.lowestLow).toBe(90); + expect(r.stop).toBe(96); +}); + +test('Short-Stop steigt NIE — auch wenn ATR explodiert', () => { + // ll bleibt 90, ATR springt auf 10 → Kandidat 90+30=120, aber Stop bleibt 96 + const r = updateChandelierShort({ lowestLow: 90, stop: 96 }, 95, 10, 3); + expect(r.lowestLow).toBe(90); + expect(r.stop).toBe(96); +}); + +test('Short NaN-ATR lässt Stop unverändert', () => { + const r = updateChandelierShort({ lowestLow: 90, stop: 96 }, 80, NaN, 3); + expect(r.lowestLow).toBe(80); + expect(r.stop).toBe(96); +}); + +test('Short-Stop ratchtet runter wenn ATR schrumpft, auch ohne neues Tief', () => { + // ll bleibt 90, ATR schrumpft von 3 auf 1 → Stop 90+3 = 93 < vorher 96 + const r = updateChandelierShort({ lowestLow: 90, stop: 96 }, 95, 1, 3); + expect(r.lowestLow).toBe(90); + expect(r.stop).toBe(93); +}); diff --git a/src/server/strategy/chandelier.ts b/src/server/strategy/chandelier.ts index 4174a6a..6f377a3 100644 --- a/src/server/strategy/chandelier.ts +++ b/src/server/strategy/chandelier.ts @@ -9,3 +9,15 @@ export function updateChandelier(state: TrailState, barHigh: number, atrValue: n const candidate = Number.isNaN(atrValue) ? -Infinity : highestHigh - mult * atrValue; return { highestHigh, stop: Math.max(state.stop, candidate) }; } + +export interface ShortTrailState { + lowestLow: number; + stop: number; +} + +/** Spiegelbild für Shorts: ll + mult×ATR, wandert nur abwärts. */ +export function updateChandelierShort(state: ShortTrailState, barLow: number, atrValue: number, mult: number): ShortTrailState { + const lowestLow = Math.min(state.lowestLow, barLow); + const candidate = Number.isNaN(atrValue) ? Infinity : lowestLow + mult * atrValue; + return { lowestLow, stop: Math.min(state.stop, candidate) }; +} diff --git a/src/server/strategy/donchian-trend.test.ts b/src/server/strategy/donchian-trend.test.ts index 5ab14d7..4b29229 100644 --- a/src/server/strategy/donchian-trend.test.ts +++ b/src/server/strategy/donchian-trend.test.ts @@ -60,3 +60,71 @@ test('blockiert bei zu wenig Daten', () => { test('DEFAULT_PARAMS entsprechen der Spec', () => { expect(DEFAULT_PARAMS).toEqual({ donchianPeriod: 20, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 200 }); }); + +/** Abwärtstrend, letzte Candle bricht unter das 3er-Tief aus. */ +function breakdownSeries(): Candle[] { + const s: Candle[] = []; + // Fallende Reihe: Starts bei 20, fällt um je 1 pro Candle + for (let i = 0; i < 7; i++) s.push(c(20 - i, 21 - i, 19 - i, 19.5 - i, i)); + // bisheriges 3er-Tief: min(low[4..6]) = min(15,14,13) = 13 → Close 11 bricht aus, weit unter EMA5 + s.push(c(13, 14, 10.5, 11, 7)); + return s; +} + +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); + expect(ev.signal).toBe('short'); + expect(ev.blockedBy).toBeNull(); + expect(ev.donchianLow).toBe(13); // min(low[4..6]) = min(15,14,13) = 13 +}); + +test('Gleiche Daten mit allowShort=false → kein Short, blockiert', () => { + const c4h = breakdownSeries(); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, false); + expect(ev.signal).toBeNull(); + // close=11 <= donchianHigh (whatever it is) → below_donchian + expect(ev.blockedBy).toBe('below_donchian'); +}); + +test('Breakdown aber Close > EMA → kein Short (Aufwärtstrend-Filter)', () => { + // Struktur: hohe EMA durch früheren Bullenmarkt, dann kurzer Pullback + const s: Candle[] = []; + // Hohe Ausgangswerte damit EMA5 hoch bleibt + s.push(c(200, 201, 199, 200, 0)); + s.push(c(200, 201, 199, 200, 1)); + s.push(c(200, 201, 199, 200, 2)); + s.push(c(200, 201, 199, 200, 3)); + s.push(c(200, 201, 199, 200, 4)); + s.push(c(200, 201, 199, 200, 5)); + s.push(c(200, 201, 199, 200, 6)); + // Index 7: donchianLow[7] = min(low[4..6]) = min(199,199,199) = 199 + // Close 198 < donchianLow(199), aber EMA5 ≈ 200 → Close < donchianLow aber > ... wait + // Wir brauchen Close < donchianLow AND Close > trendEma — unmöglich da donchianLow <= ema hier + // Stattdessen: close ist ZWISCHEN donchianLow und trendEma → kein Short weil close >= donchianLow + // Einfacherer Fall: close < donchianLow aber close > trendEma + // Erzwingen: trendEma sehr niedrig (kurze Serie), aber donchianLow noch höher als close + // Nutze P.trendEmaPeriod=5: nach 7 Candles @ 200 ist EMA5 ≈ 200 + // Close 150 < donchianLow(199), aber EMA5 ≈ 200 > Close → das wäre short-Signal (close < ema) + // Für "close OVER ema" → no short: wir brauchen close < donchianLow UND close > trendEma + // Das geht mit schnell fallender EMA: viele niedrige Werte, dann ein einzelner Ausreißer unten + // Einfacher: nutze Initialwerte die EMA unter donchianLow drücken + // Serie: low values für EMA, dann hohe Lows für donchian (damit donchianLow hoch ist) + const t: Candle[] = []; + // 4 Candles bei 10 → EMA5 startet warm bei 10 + t.push(c(10, 11, 9, 10, 0)); + t.push(c(10, 11, 9, 10, 1)); + t.push(c(10, 11, 9, 10, 2)); + // 3 Candles bei 50 → EMA5 steigt auf ~27, Highs/Lows bei 51/49 + t.push(c(50, 51, 49, 50, 3)); + t.push(c(50, 51, 49, 50, 4)); + t.push(c(50, 51, 49, 50, 5)); + // 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); + // 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 +}); diff --git a/src/server/strategy/donchian-trend.ts b/src/server/strategy/donchian-trend.ts index 3040ac6..47faca5 100644 --- a/src/server/strategy/donchian-trend.ts +++ b/src/server/strategy/donchian-trend.ts @@ -1,7 +1,7 @@ import type { Candle } from '../types'; import { ema } from '../indicators/ema'; import { atr } from '../indicators/atr'; -import { donchianHigh } from '../indicators/donchian'; +import { donchianHigh, donchianLow } from '../indicators/donchian'; export interface StrategyParams { donchianPeriod: number; @@ -20,15 +20,17 @@ export const DEFAULT_PARAMS: StrategyParams = { export interface IndicatorSet { trendEma: number[]; donchianHigh: number[]; + donchianLow: number[]; atr: number[]; } export interface Evaluation { - signal: 'long' | null; + signal: 'long' | 'short' | null; blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null; close: number; atr: number; donchianHigh: number; + donchianLow: number; trendEma: number; } @@ -37,24 +39,35 @@ export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSe return { trendEma: ema(c4h.map((c) => c.close), p.trendEmaPeriod), donchianHigh: donchianHigh(c4h, p.donchianPeriod), + donchianLow: donchianLow(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): Evaluation { +export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowShort = false): Evaluation { const close = c4h[i]?.close ?? NaN; - const base = { close, atr: ind.atr[i], donchianHigh: ind.donchianHigh[i], trendEma: ind.trendEma[i] }; + const base = { + close, atr: ind.atr[i], donchianHigh: ind.donchianHigh[i], + donchianLow: ind.donchianLow[i], trendEma: ind.trendEma[i], + }; if ( i < 0 || i >= c4h.length || Number.isNaN(ind.trendEma[i]) || Number.isNaN(ind.donchianHigh[i]) || + Number.isNaN(ind.donchianLow[i]) || Number.isNaN(ind.atr[i]) ) { return { signal: null, blockedBy: 'insufficient_data', ...base }; } + if (close > ind.donchianHigh[i] && close > ind.trendEma[i]) { + return { signal: 'long', blockedBy: null, ...base }; + } + if (allowShort && close < ind.donchianLow[i] && close < ind.trendEma[i]) { + return { signal: 'short', blockedBy: null, ...base }; + } + // Blocked reasons from the long perspective 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 }; + return { signal: null, blockedBy: 'below_trend_ema', ...base }; }