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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user