diff --git a/src/server/engine/portfolio.test.ts b/src/server/engine/portfolio.test.ts new file mode 100644 index 0000000..0db11b1 --- /dev/null +++ b/src/server/engine/portfolio.test.ts @@ -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 * (1−0.0005) = 109.945; Proceeds−Fee = 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); +}); diff --git a/src/server/engine/portfolio.ts b/src/server/engine/portfolio.ts new file mode 100644 index 0000000..2b94fd2 --- /dev/null +++ b/src/server/engine/portfolio.ts @@ -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(); + 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): number { + let eq = this.cash; + for (const pos of this.positions.values()) { + eq += pos.qty * (lastClose.get(pos.pair) ?? pos.entryPrice); + } + return eq; + } +}