/** * Test suite for position sizing calculations */ import { describe, it, expect } from 'bun:test'; import { fixedRiskPositionSize, kellyPositionSize, fractionalKellyPositionSize, volatilityTargetPositionSize, equalWeightPositionSize, atrBasedPositionSize, expectancyPositionSize, monteCarloPositionSize, sharpeOptimizedPositionSize, fixedFractionalPositionSize, volatilityAdjustedPositionSize, correlationAdjustedPositionSize, calculatePortfolioHeat, dynamicPositionSize, liquidityConstrainedPositionSize, multiTimeframePositionSize, riskParityPositionSize, validatePositionSize, type PositionSizeParams, type KellyParams, type VolatilityParams } from '../../src/calculations/position-sizing'; describe('Position Sizing Calculations', () => { describe('fixedRiskPositionSize', () => { it('should calculate correct position size for long position', () => { const params: PositionSizeParams = { accountSize: 100000, riskPercentage: 2, entryPrice: 100, stopLoss: 95, leverage: 1 }; const result = fixedRiskPositionSize(params); // Risk amount: 100000 * 0.02 = 2000 // Risk per share: 100 - 95 = 5 // Position size: 2000 / 5 = 400 shares expect(result).toBe(400); }); it('should calculate correct position size for short position', () => { const params: PositionSizeParams = { accountSize: 100000, riskPercentage: 2, entryPrice: 100, stopLoss: 105, leverage: 1 }; const result = fixedRiskPositionSize(params); // Risk per share: |100 - 105| = 5 // Position size: 2000 / 5 = 400 shares expect(result).toBe(400); }); it('should return 0 for invalid inputs', () => { const params: PositionSizeParams = { accountSize: 0, riskPercentage: 2, entryPrice: 100, stopLoss: 95 }; expect(fixedRiskPositionSize(params)).toBe(0); }); it('should return 0 when entry price equals stop loss', () => { const params: PositionSizeParams = { accountSize: 100000, riskPercentage: 2, entryPrice: 100, stopLoss: 100 }; expect(fixedRiskPositionSize(params)).toBe(0); }); }); describe('kellyPositionSize', () => { it('should calculate correct Kelly position size', () => { const params: KellyParams = { winRate: 0.6, averageWin: 150, averageLoss: -100 }; const result = kellyPositionSize(params, 100000); // Kelly formula: f = (bp - q) / b // b = 150/100 = 1.5, p = 0.6, q = 0.4 // f = (1.5 * 0.6 - 0.4) / 1.5 = (0.9 - 0.4) / 1.5 = 0.5 / 1.5 = 0.333 // With safety factor of 0.25: 0.333 * 0.25 = 0.083 // Capped at 0.25, so result should be 0.083 // Position: 100000 * 0.083 = 8300 expect(result).toBeCloseTo(8333, 0); }); it('should return 0 for negative expectancy', () => { const params: KellyParams = { winRate: 0.3, averageWin: 100, averageLoss: -200 }; const result = kellyPositionSize(params, 100000); expect(result).toBe(0); }); it('should return 0 for invalid inputs', () => { const params: KellyParams = { winRate: 0, averageWin: 100, averageLoss: -100 }; expect(kellyPositionSize(params, 100000)).toBe(0); }); }); describe('volatilityTargetPositionSize', () => { it('should calculate correct volatility-targeted position size', () => { const params: VolatilityParams = { price: 100, volatility: 0.20, targetVolatility: 0.10, lookbackDays: 30 }; const result = volatilityTargetPositionSize(params, 100000); // Volatility ratio: 0.10 / 0.20 = 0.5 // Position value: 100000 * 0.5 = 50000 // Position size: 50000 / 100 = 500 shares expect(result).toBe(500); }); it('should cap leverage at 2x', () => { const params: VolatilityParams = { price: 100, volatility: 0.05, targetVolatility: 0.20, lookbackDays: 30 }; const result = volatilityTargetPositionSize(params, 100000); // Volatility ratio would be 4, but capped at 2 // Position value: 100000 * 2 = 200000 // Position size: 200000 / 100 = 2000 shares expect(result).toBe(2000); }); }); describe('equalWeightPositionSize', () => { it('should calculate equal weight position size', () => { const result = equalWeightPositionSize(100000, 5, 100); // Position value per asset: 100000 / 5 = 20000 // Position size: 20000 / 100 = 200 shares expect(result).toBe(200); }); it('should return 0 for invalid inputs', () => { expect(equalWeightPositionSize(100000, 0, 100)).toBe(0); expect(equalWeightPositionSize(100000, 5, 0)).toBe(0); }); }); describe('atrBasedPositionSize', () => { it('should calculate ATR-based position size', () => { const result = atrBasedPositionSize(100000, 2, 5, 2, 100); // Risk amount: 100000 * 0.02 = 2000 // Stop distance: 5 * 2 = 10 // Position size: 2000 / 10 = 200 shares expect(result).toBe(200); }); it('should return 0 for zero ATR', () => { const result = atrBasedPositionSize(100000, 2, 0, 2, 100); expect(result).toBe(0); }); }); describe('expectancyPositionSize', () => { it('should calculate expectancy-based position size', () => { const result = expectancyPositionSize(100000, 0.6, 150, -100, 5); // Expectancy: 0.6 * 150 - 0.4 * 100 = 90 - 40 = 50 // Expectancy ratio: 50 / 100 = 0.5 // Risk percentage: min(0.5 * 0.5, 5) = min(0.25, 5) = 0.25 // Position: 100000 * 0.0025 = 250 expect(result).toBe(250); }); it('should return 0 for negative expectancy', () => { const result = expectancyPositionSize(100000, 0.3, 100, -200); expect(result).toBe(0); }); }); describe('correlationAdjustedPositionSize', () => { it('should adjust position size based on correlation', () => { const existingPositions = [ { size: 1000, correlation: 0.5 }, { size: 500, correlation: 0.3 } ]; const result = correlationAdjustedPositionSize(1000, existingPositions, 0.5); // Should reduce position size based on correlation risk expect(result).toBeLessThan(1000); expect(result).toBeGreaterThan(0); }); it('should return original size when no existing positions', () => { const result = correlationAdjustedPositionSize(1000, [], 0.5); expect(result).toBe(1000); }); }); describe('calculatePortfolioHeat', () => { it('should calculate portfolio heat correctly', () => { const positions = [ { value: 10000, risk: 500 }, { value: 15000, risk: 750 }, { value: 20000, risk: 1000 } ]; const result = calculatePortfolioHeat(positions, 100000); // Total risk: 500 + 750 + 1000 = 2250 // Heat: (2250 / 100000) * 100 = 2.25% expect(result).toBe(2.25); }); it('should handle empty positions array', () => { const result = calculatePortfolioHeat([], 100000); expect(result).toBe(0); }); it('should cap heat at 100%', () => { const positions = [ { value: 50000, risk: 150000 } ]; const result = calculatePortfolioHeat(positions, 100000); expect(result).toBe(100); }); }); describe('dynamicPositionSize', () => { it('should adjust position size based on market conditions', () => { const result = dynamicPositionSize(1000, 0.25, 0.15, 0.05, 0.10); // Volatility adjustment: 0.15 / 0.25 = 0.6 // Drawdown adjustment: 1 - (0.05 / 0.10) = 0.5 // Adjusted size: 1000 * 0.6 * 0.5 = 300 expect(result).toBe(300); }); it('should handle high drawdown', () => { const result = dynamicPositionSize(1000, 0.20, 0.15, 0.15, 0.10); // Should significantly reduce position size due to high drawdown expect(result).toBeLessThan(500); }); }); describe('liquidityConstrainedPositionSize', () => { it('should constrain position size based on liquidity', () => { const result = liquidityConstrainedPositionSize(1000, 10000, 0.05, 100); // Max shares: 10000 * 0.05 = 500 // Should return min(1000, 500) = 500 expect(result).toBe(500); }); it('should return desired size when liquidity allows', () => { const result = liquidityConstrainedPositionSize(500, 20000, 0.05, 100); // Max shares: 20000 * 0.05 = 1000 // Should return min(500, 1000) = 500 expect(result).toBe(500); }); }); describe('multiTimeframePositionSize', () => { it('should weight signals correctly', () => { const result = multiTimeframePositionSize(100000, 0.8, 0.6, 0.4, 2); // Weighted signal: 0.8 * 0.2 + 0.6 * 0.3 + 0.4 * 0.5 = 0.16 + 0.18 + 0.2 = 0.54 // Adjusted risk: 2 * 0.54 = 1.08% // Position: 100000 * 0.0108 = 1080 expect(result).toBe(1080); }); it('should clamp signals to valid range', () => { const result = multiTimeframePositionSize(100000, 2, -2, 1.5, 2); // Signals should be clamped to [-1, 1] // Weighted: 1 * 0.2 + (-1) * 0.3 + 1 * 0.5 = 0.2 - 0.3 + 0.5 = 0.4 // Adjusted risk: 2 * 0.4 = 0.8% expect(result).toBe(800); }); }); describe('riskParityPositionSize', () => { it('should allocate based on inverse volatility', () => { const assets = [ { volatility: 0.10, price: 100 }, { volatility: 0.20, price: 200 } ]; const result = riskParityPositionSize(assets, 0.15, 100000); // Asset 1: 1/0.10 = 10, Asset 2: 1/0.20 = 5 // Total inverse vol: 15 // Weights: Asset 1: 10/15 = 0.667, Asset 2: 5/15 = 0.333 expect(result).toHaveLength(2); expect(result[0]).toBeGreaterThan(result[1]); }); it('should handle zero volatility assets', () => { const assets = [ { volatility: 0, price: 100 }, { volatility: 0.20, price: 200 } ]; const result = riskParityPositionSize(assets, 0.15, 100000); expect(result[0]).toBe(0); expect(result[1]).toBeGreaterThan(0); }); }); describe('sharpeOptimizedPositionSize', () => { it('should calculate position size based on Sharpe optimization', () => { const result = sharpeOptimizedPositionSize(100000, 0.15, 0.20, 0.02, 3); // Kelly formula for continuous returns: f = (μ - r) / σ² // Expected return: 0.15, Risk-free: 0.02, Volatility: 0.20 // f = (0.15 - 0.02) / (0.20)² = 0.13 / 0.04 = 3.25 // But capped at maxLeverage=3, so should be 3.0 // Final position: 100000 * 3 = 300000 expect(result).toBe(300000); }); it('should return 0 for invalid inputs', () => { // Invalid volatility expect(sharpeOptimizedPositionSize(100000, 0.15, 0, 0.02)).toBe(0); // Invalid account size expect(sharpeOptimizedPositionSize(0, 0.15, 0.20, 0.02)).toBe(0); // Expected return less than risk-free rate expect(sharpeOptimizedPositionSize(100000, 0.01, 0.20, 0.02)).toBe(0); }); it('should respect maximum leverage', () => { const result = sharpeOptimizedPositionSize(100000, 0.30, 0.20, 0.02, 2); // Kelly fraction would be (0.30 - 0.02) / (0.20)² = 7, but capped at 2 // Position: 100000 * 2 = 200000 expect(result).toBe(200000); }); }); describe('validatePositionSize', () => { it('should validate position size against limits', () => { const result = validatePositionSize(500, 100, 100000, 10, 2); // Position value: 500 * 100 = 50000 (50% of account) // This exceeds 10% limit expect(result.isValid).toBe(false); expect(result.violations).toContain('Position exceeds maximum 10% of account'); expect(result.adjustedSize).toBe(100); // 10000 / 100 }); it('should pass validation for reasonable position', () => { const result = validatePositionSize(50, 100, 100000, 10, 2); // Position value: 50 * 100 = 5000 (5% of account) expect(result.isValid).toBe(true); expect(result.violations).toHaveLength(0); expect(result.adjustedSize).toBe(50); }); it('should handle fractional shares', () => { const result = validatePositionSize(0.5, 100, 100000, 10, 2); expect(result.isValid).toBe(false); expect(result.violations).toContain('Position size too small (less than 1 share)'); expect(result.adjustedSize).toBe(0); }); }); });