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

@@ -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*(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;
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<Pair, number>): 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;
}

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