feat: Paper-Portfolio mit Fees, Slippage, R-Multiples

This commit is contained in:
2026-06-09 20:31:00 +00:00
parent 7d576c61c5
commit edf6793695
2 changed files with 107 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import { expect, test } from 'bun:test';
import { Portfolio, DEFAULT_EXEC } from './portfolio';
test('Entry: Slippage verteuert Fill, Fee reduziert Cash', () => {
const p = new Portfolio(1000, DEFAULT_EXEC);
p.open('BTC_USDT', 0, 100, 94, 1, 10); // pair, ts, signalPrice, initialStop, qty, riskAmount
const pos = p.positions.get('BTC_USDT')!;
expect(pos.entryPrice).toBeCloseTo(100.05); // 100 * (1 + 0.0005)
// Cash: 1000 100.05 0.10005 (Fee 0.1%)
expect(p.cash).toBeCloseTo(1000 - 100.05 - 0.10005);
});
test('Exit: PnL netto inkl. beider Fees und Slippage, R-Multiple korrekt', () => {
const p = new Portfolio(1000, DEFAULT_EXEC);
p.open('BTC_USDT', 0, 100, 94, 1, 10);
const trade = p.close('BTC_USDT', 1, 110, 'trailing_stop');
// Exit-Fill: 110 * (10.0005) = 109.945; ProceedsFee = 109.945 * 0.999
const cost = 100.05 + 0.10005;
const net = 109.945 * 0.999 - cost;
expect(trade.pnl).toBeCloseTo(net);
expect(trade.r).toBeCloseTo(net / 10);
expect(p.positions.size).toBe(0);
});
test('Equity = Cash + Marktwert offener Positionen', () => {
const p = new Portfolio(1000, DEFAULT_EXEC);
p.open('BTC_USDT', 0, 100, 94, 2, 10);
expect(p.equity(new Map([['BTC_USDT', 105]]))).toBeCloseTo(p.cash + 2 * 105);
});

View File

@@ -0,0 +1,78 @@
import type { Pair } from '../types';
export interface ExecConfig {
feeRate: number; // 0.001 pro Seite
slippage: number; // 0.0005 pro Seite
}
export const DEFAULT_EXEC: ExecConfig = { feeRate: 0.001, slippage: 0.0005 };
export interface Position {
pair: Pair;
qty: number;
entryTs: number;
entryPrice: number; // Fill inkl. Slippage
entryCost: number; // qty*fill + Entry-Fee
initialStop: number;
stop: number;
highestHigh: number;
riskAmount: number;
}
export interface ClosedTrade {
pair: Pair;
entryTs: number;
entryPrice: number;
exitTs: number;
exitPrice: number;
qty: number;
pnl: number;
r: number;
exitReason: 'trailing_stop' | 'end_of_data';
}
export class Portfolio {
cash: number;
positions = new Map<Pair, Position>();
trades: ClosedTrade[] = [];
constructor(startCapital: number, private exec: ExecConfig) {
this.cash = startCapital;
}
open(pair: Pair, ts: number, signalPrice: number, initialStop: number, qty: number, riskAmount: number): void {
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,
});
}
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;
const trade: ClosedTrade = {
pair, entryTs: pos.entryTs, entryPrice: pos.entryPrice, exitTs: ts,
exitPrice: fill, qty: pos.qty, pnl, r: pnl / pos.riskAmount, exitReason,
};
this.trades.push(trade);
this.positions.delete(pair);
return trade;
}
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);
}
return eq;
}
}