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:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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*(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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 110−6 = 104
|
// 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.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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user