fix: Review-Fixes Sizing/Portfolio (invalid_stop-Guard, Doppel-Open-Schutz, Tests)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user