feat: Short-Seite — Indikator, Strategie, Chandelier, Sizing, Portfolio

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:29:30 +00:00
parent 26166c5f3c
commit 0e1b477e27
11 changed files with 289 additions and 30 deletions

View File

@@ -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 1106 = 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);
});

View File

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

View File

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

View File

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