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 /** Long: Kosten (qty×fill + Entry-Fee); Short: Netto-Erlös (proceeds − Entry-Fee). */ entryCost: number; initialStop: number; stop: number; /** Long: höchstes Hoch seit Entry; Short: tiefstes Tief seit Entry. */ trailExtreme: number; riskAmount: number; side: 'long' | 'short'; } 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'; side: 'long' | 'short'; } 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, side: 'long' | 'short' = 'long'): void { if (this.positions.has(pair)) throw new Error(`open auf bestehende Position: ${pair}`); if (side === 'short') { const fill = signalPrice * (1 - this.exec.slippage); const proceeds = qty * fill; const fee = proceeds * this.exec.feeRate; this.cash += proceeds - fee; this.positions.set(pair, { pair, qty, entryTs: ts, entryPrice: fill, entryCost: proceeds - fee, // Netto-Erlös des Leerverkaufs initialStop, stop: initialStop, trailExtreme: signalPrice, // Markt-Preis, nicht Fill riskAmount, side, }); } else { 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, // Kosten des Kaufs initialStop, stop: initialStop, trailExtreme: signalPrice, // Markt-Preis, nicht Fill: der Chandelier trailt Markt-Hochs, Slippage ist Ausführungsartefakt riskAmount, side, }); } } 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}`); let pnl: number; let fill: number; if (pos.side === 'short') { // Deckungskauf: schlechterer Fill = höherer Preis fill = exitPrice * (1 + this.exec.slippage); const cost = pos.qty * fill; const fee = cost * this.exec.feeRate; this.cash -= cost + fee; pnl = pos.entryCost - (cost + fee); } else { fill = exitPrice * (1 - this.exec.slippage); const proceeds = pos.qty * fill; const fee = proceeds * this.exec.feeRate; this.cash += proceeds - fee; 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, side: pos.side, }; 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()) { const last = lastClose.get(pos.pair) ?? pos.entryPrice; if (pos.side === 'short') { // Proceeds already in cash; owe qty×last to close eq -= pos.qty * last; } else { eq += pos.qty * last; } } return eq; } }