diff --git a/src/server/backtest/metrics.ts b/src/server/backtest/metrics.ts index 9a404cf..48f42b8 100644 --- a/src/server/backtest/metrics.ts +++ b/src/server/backtest/metrics.ts @@ -29,7 +29,7 @@ export function computeMetrics(trades: ClosedTrade[], curve: EquityPoint[], star trades: trades.length, wins: wins.length, winRate: trades.length ? wins.length / trades.length : 0, - profitFactor: grossLoss > 0 ? grossWin / grossLoss : grossWin > 0 ? Infinity : 0, + profitFactor: grossLoss > 0 ? grossWin / grossLoss : grossWin > 0 ? Infinity : 0, // keine Trades → 0 (Spec-Konvention, nicht zu NaN/1 "fixen") totalPnl: trades.reduce((s, t) => s + t.pnl, 0), maxDrawdownPct: maxDd, avgR: trades.length ? trades.reduce((s, t) => s + t.r, 0) / trades.length : 0, diff --git a/src/server/engine/portfolio.test.ts b/src/server/engine/portfolio.test.ts index 0db11b1..2058bf3 100644 --- a/src/server/engine/portfolio.test.ts +++ b/src/server/engine/portfolio.test.ts @@ -27,3 +27,14 @@ test('Equity = Cash + Marktwert offener Positionen', () => { p.open('BTC_USDT', 0, 100, 94, 2, 10); expect(p.equity(new Map([['BTC_USDT', 105]]))).toBeCloseTo(p.cash + 2 * 105); }); + +test('open auf bestehende Position wirft', () => { + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 94, 1, 10); + expect(() => p.open('BTC_USDT', 1, 101, 95, 1, 10)).toThrow(); +}); + +test('close ohne Position wirft', () => { + const p = new Portfolio(1000, DEFAULT_EXEC); + expect(() => p.close('BTC_USDT', 0, 100, 'trailing_stop')).toThrow(); +}); diff --git a/src/server/engine/portfolio.ts b/src/server/engine/portfolio.ts index 2b94fd2..554bbf4 100644 --- a/src/server/engine/portfolio.ts +++ b/src/server/engine/portfolio.ts @@ -41,13 +41,14 @@ export class Portfolio { } open(pair: Pair, ts: number, signalPrice: number, initialStop: number, qty: number, riskAmount: number): 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, + initialStop, stop: initialStop, highestHigh: signalPrice, riskAmount, // Markt-Preis, nicht Fill: der Chandelier trailt Markt-Hochs, Slippage ist Ausführungsartefakt }); } diff --git a/src/server/engine/sizing.test.ts b/src/server/engine/sizing.test.ts index a14ba71..d9f02e8 100644 --- a/src/server/engine/sizing.test.ts +++ b/src/server/engine/sizing.test.ts @@ -25,3 +25,8 @@ test('blockiert unter Mindestordergröße', () => { expect(r.qty).toBe(0); expect(r.blockedBy).toBe('min_notional'); }); + +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'); +}); diff --git a/src/server/engine/sizing.ts b/src/server/engine/sizing.ts index 1996f78..f063fbd 100644 --- a/src/server/engine/sizing.ts +++ b/src/server/engine/sizing.ts @@ -10,7 +10,7 @@ export interface SizingResult { qty: number; notional: number; riskAmount: number; - blockedBy: 'min_notional' | null; + blockedBy: 'min_notional' | 'invalid_stop' | null; } export function sizePosition( @@ -22,6 +22,7 @@ export function sizePosition( ): SizingResult { const riskAmount = equity * cfg.riskPerTradePct; const stopDist = 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);