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

@@ -3,7 +3,7 @@ import { computeMetrics } from './metrics';
import type { ClosedTrade } from '../engine/portfolio'; import type { ClosedTrade } from '../engine/portfolio';
function t(pnl: number): ClosedTrade { 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', () => { test('ProfitFactor, WinRate, AvgR', () => {

View File

@@ -38,3 +38,48 @@ test('close ohne Position wirft', () => {
const p = new Portfolio(1000, DEFAULT_EXEC); const p = new Portfolio(1000, DEFAULT_EXEC);
expect(() => p.close('BTC_USDT', 0, 100, 'trailing_stop')).toThrow(); 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*(10.0005)=99.95, proceeds=1*99.95=99.95
// fee=99.95*0.001=0.09995, cash += 99.950.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);
});

View File

@@ -12,11 +12,14 @@ export interface Position {
qty: number; qty: number;
entryTs: number; entryTs: number;
entryPrice: number; // Fill inkl. Slippage 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; initialStop: number;
stop: number; stop: number;
highestHigh: number; /** Long: höchstes Hoch seit Entry; Short: tiefstes Tief seit Entry. */
trailExtreme: number;
riskAmount: number; riskAmount: number;
side: 'long' | 'short';
} }
export interface ClosedTrade { export interface ClosedTrade {
@@ -29,6 +32,7 @@ export interface ClosedTrade {
pnl: number; pnl: number;
r: number; r: number;
exitReason: 'trailing_stop' | 'end_of_data'; exitReason: 'trailing_stop' | 'end_of_data';
side: 'long' | 'short';
} }
export class Portfolio { export class Portfolio {
@@ -40,29 +44,57 @@ export class Portfolio {
this.cash = startCapital; 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}`); if (this.positions.has(pair)) throw new Error(`open auf bestehende Position: ${pair}`);
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 fill = signalPrice * (1 + this.exec.slippage);
const cost = qty * fill; const cost = qty * fill;
const fee = cost * this.exec.feeRate; const fee = cost * this.exec.feeRate;
this.cash -= cost + fee; this.cash -= cost + fee;
this.positions.set(pair, { this.positions.set(pair, {
pair, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee, pair, qty, entryTs: ts, entryPrice: fill,
initialStop, stop: initialStop, highestHigh: signalPrice, riskAmount, // Markt-Preis, nicht Fill: der Chandelier trailt Markt-Hochs, Slippage ist Ausführungsartefakt 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 { close(pair: Pair, ts: number, exitPrice: number, exitReason: ClosedTrade['exitReason']): ClosedTrade {
const pos = this.positions.get(pair); const pos = this.positions.get(pair);
if (!pos) throw new Error(`close ohne Position: ${pair}`); if (!pos) throw new Error(`close ohne Position: ${pair}`);
const fill = exitPrice * (1 - this.exec.slippage); 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 proceeds = pos.qty * fill;
const fee = proceeds * this.exec.feeRate; const fee = proceeds * this.exec.feeRate;
this.cash += proceeds - fee; this.cash += proceeds - fee;
const pnl = proceeds - fee - pos.entryCost; pnl = proceeds - fee - pos.entryCost;
}
const trade: ClosedTrade = { const trade: ClosedTrade = {
pair, entryTs: pos.entryTs, entryPrice: pos.entryPrice, exitTs: ts, 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.trades.push(trade);
this.positions.delete(pair); this.positions.delete(pair);
@@ -72,7 +104,13 @@ export class Portfolio {
equity(lastClose: Map<Pair, number>): number { equity(lastClose: Map<Pair, number>): number {
let eq = this.cash; let eq = this.cash;
for (const pos of this.positions.values()) { 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; return eq;
} }

View File

@@ -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, 100, DEFAULT_RISK).blockedBy).toBe('invalid_stop');
expect(sizePosition(1000, 1000, 100, 101, 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);
});

View File

@@ -19,13 +19,21 @@ export function sizePosition(
entryPrice: number, entryPrice: number,
stopPrice: number, stopPrice: number,
cfg: RiskConfig, cfg: RiskConfig,
side: 'long' | 'short' = 'long',
): SizingResult { ): SizingResult {
const riskAmount = equity * cfg.riskPerTradePct; 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' }; if (stopDist <= 0) return { qty: 0, notional: 0, riskAmount, blockedBy: 'invalid_stop' };
let notional = (riskAmount / stopDist) * entryPrice; let notional = (riskAmount / stopDist) * entryPrice;
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 // 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); notional = Math.min(notional, equity * cfg.maxPositionPct, cash * 0.997);
}
if (!(notional >= cfg.minNotionalUsdt)) return { qty: 0, notional: 0, riskAmount, blockedBy: 'min_notional' }; if (!(notional >= cfg.minNotionalUsdt)) return { qty: 0, notional: 0, riskAmount, blockedBy: 'min_notional' };
return { qty: notional / entryPrice, notional, riskAmount, blockedBy: null }; return { qty: notional / entryPrice, notional, riskAmount, blockedBy: null };
} }

View File

@@ -1,6 +1,6 @@
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import type { Candle } from '../types'; import type { Candle } from '../types';
import { donchianHigh } from './donchian'; import { donchianHigh, donchianLow } from './donchian';
function c(h: number): Candle { function c(h: number): Candle {
return { ts: 0, open: h, high: h, low: h - 1, close: h, volume: 1 }; 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[3]).toBe(12); // max(10,12,11)
expect(out[4]).toBe(12); // max(12,11,9) — Candle 4 selbst zählt nicht 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
});

View File

@@ -10,3 +10,14 @@ export function donchianHigh(candles: Candle[], period: number): number[] {
} }
return out; 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<number>(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;
}

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import { updateChandelier } from './chandelier'; import { updateChandelier, updateChandelierShort } from './chandelier';
test('Stop steigt mit neuem Hoch', () => { test('Stop steigt mit neuem Hoch', () => {
// hh 100 → 110, ATR 2, Mult 3 → Stop 1106 = 104 // 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.highestHigh).toBe(110);
expect(r.stop).toBe(107); 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; const candidate = Number.isNaN(atrValue) ? -Infinity : highestHigh - mult * atrValue;
return { highestHigh, stop: Math.max(state.stop, candidate) }; 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', () => { 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 });
}); });
/** 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 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 { donchianHigh } from '../indicators/donchian'; import { donchianHigh, donchianLow } from '../indicators/donchian';
export interface StrategyParams { export interface StrategyParams {
donchianPeriod: number; donchianPeriod: number;
@@ -20,15 +20,17 @@ export const DEFAULT_PARAMS: StrategyParams = {
export interface IndicatorSet { export interface IndicatorSet {
trendEma: number[]; trendEma: number[];
donchianHigh: number[]; donchianHigh: number[];
donchianLow: number[];
atr: number[]; atr: number[];
} }
export interface Evaluation { export interface Evaluation {
signal: 'long' | null; signal: 'long' | 'short' | null;
blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null; blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null;
close: number; close: number;
atr: number; atr: number;
donchianHigh: number; donchianHigh: number;
donchianLow: number;
trendEma: number; trendEma: number;
} }
@@ -37,24 +39,35 @@ export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSe
return { return {
trendEma: ema(c4h.map((c) => c.close), p.trendEmaPeriod), trendEma: ema(c4h.map((c) => c.close), p.trendEmaPeriod),
donchianHigh: donchianHigh(c4h, p.donchianPeriod), donchianHigh: donchianHigh(c4h, p.donchianPeriod),
donchianLow: donchianLow(c4h, p.donchianPeriod),
atr: atr(c4h, p.atrPeriod), atr: atr(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): Evaluation { export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowShort = false): Evaluation {
const close = c4h[i]?.close ?? NaN; 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 ( if (
i < 0 || i < 0 ||
i >= c4h.length || i >= c4h.length ||
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.atr[i]) Number.isNaN(ind.atr[i])
) { ) {
return { signal: null, blockedBy: 'insufficient_data', ...base }; return { signal: null, blockedBy: 'insufficient_data', ...base };
} }
if (close <= ind.donchianHigh[i]) return { signal: null, blockedBy: 'below_donchian', ...base }; if (close > ind.donchianHigh[i] && close > ind.trendEma[i]) {
if (close <= ind.trendEma[i]) return { signal: null, blockedBy: 'below_trend_ema', ...base };
return { signal: 'long', blockedBy: null, ...base }; 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 };
return { signal: null, blockedBy: 'below_trend_ema', ...base };
} }