From 7d576c61c5c0c98b1a573c12b06cdcdfa9d47911 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:30:16 +0000 Subject: [PATCH] feat: risikobasiertes Position-Sizing mit Caps --- src/server/engine/sizing.test.ts | 27 +++++++++++++++++++++++++++ src/server/engine/sizing.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/server/engine/sizing.test.ts create mode 100644 src/server/engine/sizing.ts diff --git a/src/server/engine/sizing.test.ts b/src/server/engine/sizing.test.ts new file mode 100644 index 0000000..a14ba71 --- /dev/null +++ b/src/server/engine/sizing.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test'; +import { sizePosition, DEFAULT_RISK } from './sizing'; + +test('1% Equity-Risiko bestimmt die Größe', () => { + // Equity 1000, Risiko 10 USDT; Entry 100, Stop 94 → 6 USDT Risiko/Einheit → qty 10/6 + const r = sizePosition(1000, 1000, 100, 94, DEFAULT_RISK); + expect(r.qty).toBeCloseTo(10 / 6); + expect(r.notional).toBeCloseTo((10 / 6) * 100); + expect(r.blockedBy).toBeNull(); +}); + +test('Cap bei 30% der Equity', () => { + // enger Stop: Entry 100, Stop 99.5 → ungecappt 2000 USDT Notional → Cap 300 + const r = sizePosition(1000, 1000, 100, 99.5, DEFAULT_RISK); + expect(r.notional).toBeCloseTo(300); +}); + +test('Cap durch verfügbares Cash', () => { + const r = sizePosition(1000, 100, 100, 99.5, DEFAULT_RISK); + expect(r.notional).toBeLessThanOrEqual(100); +}); + +test('blockiert unter Mindestordergröße', () => { + const r = sizePosition(1000, 5, 100, 94, DEFAULT_RISK); + expect(r.qty).toBe(0); + expect(r.blockedBy).toBe('min_notional'); +}); diff --git a/src/server/engine/sizing.ts b/src/server/engine/sizing.ts new file mode 100644 index 0000000..1996f78 --- /dev/null +++ b/src/server/engine/sizing.ts @@ -0,0 +1,30 @@ +export interface RiskConfig { + riskPerTradePct: number; // 0.01 = 1% der Equity + maxPositionPct: number; // 0.30 + minNotionalUsdt: number; // 10 +} + +export const DEFAULT_RISK: RiskConfig = { riskPerTradePct: 0.01, maxPositionPct: 0.3, minNotionalUsdt: 10 }; + +export interface SizingResult { + qty: number; + notional: number; + riskAmount: number; + blockedBy: 'min_notional' | null; +} + +export function sizePosition( + equity: number, + cash: number, + entryPrice: number, + stopPrice: number, + cfg: RiskConfig, +): SizingResult { + const riskAmount = equity * cfg.riskPerTradePct; + const stopDist = entryPrice - stopPrice; + 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); + if (!(notional >= cfg.minNotionalUsdt)) return { qty: 0, notional: 0, riskAmount, blockedBy: 'min_notional' }; + return { qty: notional / entryPrice, notional, riskAmount, blockedBy: null }; +}