fix: Review-Fixes Sizing/Portfolio (invalid_stop-Guard, Doppel-Open-Schutz, Tests)

This commit is contained in:
2026-06-09 20:35:04 +00:00
parent d738a2c9d0
commit 2fa4695f1b
5 changed files with 21 additions and 3 deletions

View File

@@ -29,7 +29,7 @@ export function computeMetrics(trades: ClosedTrade[], curve: EquityPoint[], star
trades: trades.length, trades: trades.length,
wins: wins.length, wins: wins.length,
winRate: trades.length ? wins.length / trades.length : 0, 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), totalPnl: trades.reduce((s, t) => s + t.pnl, 0),
maxDrawdownPct: maxDd, maxDrawdownPct: maxDd,
avgR: trades.length ? trades.reduce((s, t) => s + t.r, 0) / trades.length : 0, avgR: trades.length ? trades.reduce((s, t) => s + t.r, 0) / trades.length : 0,

View File

@@ -27,3 +27,14 @@ test('Equity = Cash + Marktwert offener Positionen', () => {
p.open('BTC_USDT', 0, 100, 94, 2, 10); p.open('BTC_USDT', 0, 100, 94, 2, 10);
expect(p.equity(new Map([['BTC_USDT', 105]]))).toBeCloseTo(p.cash + 2 * 105); 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();
});

View File

@@ -41,13 +41,14 @@ export class Portfolio {
} }
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): void {
if (this.positions.has(pair)) throw new Error(`open auf bestehende Position: ${pair}`);
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, 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
}); });
} }

View File

@@ -25,3 +25,8 @@ test('blockiert unter Mindestordergröße', () => {
expect(r.qty).toBe(0); expect(r.qty).toBe(0);
expect(r.blockedBy).toBe('min_notional'); 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');
});

View File

@@ -10,7 +10,7 @@ export interface SizingResult {
qty: number; qty: number;
notional: number; notional: number;
riskAmount: number; riskAmount: number;
blockedBy: 'min_notional' | null; blockedBy: 'min_notional' | 'invalid_stop' | null;
} }
export function sizePosition( export function sizePosition(
@@ -22,6 +22,7 @@ export function sizePosition(
): SizingResult { ): SizingResult {
const riskAmount = equity * cfg.riskPerTradePct; const riskAmount = equity * cfg.riskPerTradePct;
const stopDist = entryPrice - stopPrice; const stopDist = entryPrice - stopPrice;
if (stopDist <= 0) return { qty: 0, notional: 0, riskAmount, blockedBy: 'invalid_stop' };
let notional = (riskAmount / stopDist) * entryPrice; let notional = (riskAmount / stopDist) * entryPrice;
// 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);