diff --git a/jest.config.js b/jest.config.js index febfbf8..1db4d63 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,7 @@ module.exports = { '^@types/(.*)$': '/src/types/$1', }, transform: { - '^.+\\.ts$': ['ts-jest', { + '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { target: 'ES2022', module: 'commonjs', @@ -26,7 +26,20 @@ module.exports = { skipLibCheck: true, strict: true, resolveJsonModule: true, + allowJs: true, + }, + }], + '^.+\\.(js|jsx)$': ['ts-jest', { + tsconfig: { + target: 'ES2022', + module: 'commonjs', + esModuleInterop: true, + skipLibCheck: true, + allowJs: true, }, }], }, + transformIgnorePatterns: [ + 'node_modules/(?!(@paradex|@polymarket)/)', + ], }; diff --git a/src/services/signal/llm-analyzer.ts b/src/services/signal/llm-analyzer.ts index a620ca7..c59e914 100644 --- a/src/services/signal/llm-analyzer.ts +++ b/src/services/signal/llm-analyzer.ts @@ -82,6 +82,14 @@ function getRateLimiter(model: string) { }); } +/** + * Reset all circuit breakers (for testing) + */ +export function resetCircuitBreakers(): void { + circuitBreakers.forEach(cb => cb.reset()); + circuitBreakers.clear(); +} + // ============================================================================= // ENTRY ANALYSIS // ============================================================================= diff --git a/tests/integration/live-trading-resilience.test.ts b/tests/integration/live-trading-resilience.test.ts index 16ec6d8..359de05 100644 --- a/tests/integration/live-trading-resilience.test.ts +++ b/tests/integration/live-trading-resilience.test.ts @@ -3,7 +3,7 @@ * Tests system behavior under LLM failures */ -import { analyzeEntry } from '../../src/services/signal/llm-analyzer'; +import { analyzeEntry, resetCircuitBreakers } from '../../src/services/signal/llm-analyzer'; import type { TechnicalIndicators, Kline } from '../../src/types'; // Mock fetch @@ -45,6 +45,12 @@ jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), info: jest.fn(), }, + logger: { + debug: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, })); // Mock metrics @@ -109,6 +115,7 @@ describe('Live Trading Resilience', () => { beforeEach(() => { jest.clearAllMocks(); (global.fetch as jest.Mock).mockClear(); + resetCircuitBreakers(); // Reset circuit breaker state between tests }); describe('System continues trading with LLM failures', () => { diff --git a/tests/integration/llm-integration.test.ts b/tests/integration/llm-integration.test.ts index 847a2c3..56dca95 100644 --- a/tests/integration/llm-integration.test.ts +++ b/tests/integration/llm-integration.test.ts @@ -3,7 +3,7 @@ * Tests end-to-end LLM integration with retry, circuit breaker, and rate limiting */ -import { analyzeEntry, analyzeExit } from '../../src/services/signal/llm-analyzer'; +import { analyzeEntry, analyzeExit, resetCircuitBreakers } from '../../src/services/signal/llm-analyzer'; import type { TechnicalIndicators, Position, Kline } from '../../src/types'; // Mock fetch @@ -45,6 +45,12 @@ jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), info: jest.fn(), }, + logger: { + debug: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, })); // Mock metrics @@ -127,6 +133,7 @@ describe('LLM Integration', () => { beforeEach(() => { jest.clearAllMocks(); (global.fetch as jest.Mock).mockClear(); + resetCircuitBreakers(); // Reset circuit breaker state between tests }); describe('End-to-end entry analysis', () => { diff --git a/tests/unit/advanced-exit-logic.test.ts b/tests/unit/advanced-exit-logic.test.ts new file mode 100644 index 0000000..0d56d41 --- /dev/null +++ b/tests/unit/advanced-exit-logic.test.ts @@ -0,0 +1,785 @@ +/** + * Advanced Exit Logic Unit Tests + * Tests for partial profit taking, profit lock, peak protection, and break-even exits + */ + +import { updatePosition } from '../../src/services/position/position-manager'; +import type { Position, ScalperConfig } from '../../src/types'; + +// ============================================================================= +// TEST DATA +// ============================================================================= + +const defaultConfig: ScalperConfig = { + leverage: 10, + positionSizePercent: 35, + positionSizeUSD: null, + minPositionSizeUSD: 10, + maxPositionSizeUSD: 150, + maxExposurePercent: 80, + maxPositions: 20, + riskPerTradePercent: 2, + maxDailyLossPercent: 10, + maxDrawdownPercent: 20, + dailyProfitTargetPercent: 0, + tickIntervalMs: 15000, + scanIntervalTicks: 2, + maxHoldTimeMinutes: 15, + takeProfitROE: 1.5, + stopLossROE: -0.4, + minProfitUSD: 0.2, + trailingActivationROE: 0.5, + trailingDistanceROE: 0.2, + minIvishXConfidence: 5, + minCombinedConfidence: 55, + requireLLMAgreement: false, + minConfidenceWithoutLLM: 50, + minScoreForSignal: 50, + rsiPeriod: 14, + rsiOversold: 35, + rsiOverbought: 65, + momentumPeriod: 3, + minMomentum: 0.2, + maxMomentum: 3.0, + volumePeriod: 20, + minVolumeRatio: 0.3, + trendSMAFast: 10, + trendSMASlow: 20, + klineInterval: '5m', + klineCount: 60, + llmEnabled: false, + llmConfidenceBoost: 15, + llmExitAnalysisEnabled: false, + llmExitAnalysisMinutes: 2, + llmExitConfidenceThreshold: 80, + bounceDetectionEnabled: true, + bounceRSIThreshold: 35, + bounceStochThreshold: 25, + bounceWilliamsThreshold: -75, + bounceMinGreenCandles: 2, + bounceBonusPoints: 20, + partialProfitEnabled: true, + partialProfitROE: 1.0, + partialProfitPercent: 50, + dynamicTPEnabled: false, +}; + +function createTestPosition(overrides: Partial = {}): Position { + const base: Position = { + id: 'test-pos-1', + agentId: 'test-agent', + userId: 'test-user', + symbol: 'BTCUSDT', + side: 'long', + size: 0.001, + entryPrice: 50000, + currentPrice: 50000, + leverage: 10, + marginUsed: 5, // 0.001 * 50000 / 10 = $5 + unrealizedPnl: 0, + unrealizedROE: 0, + highestROE: 0, + lowestROE: 0, + stopLoss: null, + takeProfit: null, + trailingActivated: false, + trailingStopPrice: null, + ivishxConfidence: 7, + llmConfidence: 65, + entryReason: ['Test entry'], + openedAt: Date.now(), + updatedAt: Date.now(), + maxHoldTime: 5 * 60 * 1000, + originalSize: 0.001, + partialProfitTaken: false, + }; + return { ...base, ...overrides }; +} + +// ============================================================================= +// PARTIAL PROFIT TAKING TESTS +// ============================================================================= + +describe('Partial Profit Taking', () => { + test('should trigger partial profit at configured ROE', () => { + const config = { ...defaultConfig, partialProfitEnabled: true, partialProfitROE: 1.0, minProfitUSD: 0.01 }; + const position = createTestPosition({ + entryPrice: 50000, + size: 0.01, // Larger size for more PnL + originalSize: 0.01, + marginUsed: 50, // 0.01 * 50000 / 10 + partialProfitTaken: false, + breakEvenActivated: true, // Already activated, won't trigger again + breakEvenStopPrice: 50010, // Set far enough away + }); + + // Price moves up 1% -> 1% * 10x leverage = 10% ROE... but that's too much + // For 1% ROE at 10x leverage, we need 0.1% price move + // 50000 * 1.001 = 50050, PnL = 50 * 0.01 = $0.50 + const currentPrice = 50050; + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_partial'); + expect(result.reason).toContain('Partial profit'); + expect(result.reason).toContain('1.00%'); + }); + + test('should not trigger partial profit if already taken', () => { + const config = { ...defaultConfig, partialProfitEnabled: true, partialProfitROE: 1.0 }; + const position = createTestPosition({ + entryPrice: 50000, + originalSize: 0.001, + partialProfitTaken: true, // Already taken + }); + + const currentPrice = 50050; // Would be 1% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).not.toBe('close_partial'); + }); + + test('should not trigger partial profit when disabled', () => { + const config = { ...defaultConfig, partialProfitEnabled: false, partialProfitROE: 1.0 }; + const position = createTestPosition({ + entryPrice: 50000, + originalSize: 0.001, + partialProfitTaken: false, + }); + + const currentPrice = 50050; + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).not.toBe('close_partial'); + }); + + test('should not trigger partial profit if below minimum profit USD', () => { + const config = { + ...defaultConfig, + partialProfitEnabled: true, + partialProfitROE: 1.0, + minProfitUSD: 10, // High minimum + }; + const position = createTestPosition({ + entryPrice: 50000, + size: 0.001, // Small position + originalSize: 0.001, + partialProfitTaken: false, + }); + + const currentPrice = 50050; // 1% ROE but only $0.50 profit + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).not.toBe('close_partial'); + }); + + test('should not trigger partial profit if at full take profit', () => { + const config = { + ...defaultConfig, + partialProfitEnabled: true, + partialProfitROE: 1.0, + takeProfitROE: 1.5, + minProfitUSD: 0.01, + }; + const position = createTestPosition({ + entryPrice: 50000, + size: 0.01, + originalSize: 0.01, + marginUsed: 50, + partialProfitTaken: false, + breakEvenActivated: true, + breakEvenStopPrice: 50010, + }); + + // At full TP ROE - PnL = 75 * 0.01 = $0.75 + const currentPrice = 50075; // 1.5% ROE + + const result = updatePosition(position, currentPrice, config); + + // Should hit full TP instead + expect(result.action).toBe('close_tp'); + }); + + test('should trigger between partial and full TP', () => { + const config = { + ...defaultConfig, + partialProfitEnabled: true, + partialProfitROE: 1.0, + takeProfitROE: 1.5, + minProfitUSD: 0.01, + }; + const position = createTestPosition({ + entryPrice: 50000, + size: 0.01, + originalSize: 0.01, + marginUsed: 50, + partialProfitTaken: false, + breakEvenActivated: true, + breakEvenStopPrice: 50010, + }); + + // Between partial (1.0%) and full TP (1.5%) - PnL = 60 * 0.01 = $0.60 + const currentPrice = 50060; // ~1.2% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_partial'); + }); + + test('should work for short positions', () => { + const config = { ...defaultConfig, partialProfitEnabled: true, partialProfitROE: 1.0, minProfitUSD: 0.01 }; + const position = createTestPosition({ + side: 'short', + entryPrice: 50000, + size: 0.01, + originalSize: 0.01, + marginUsed: 50, + partialProfitTaken: false, + breakEvenActivated: true, + breakEvenStopPrice: 49990, + }); + + // Price moves down 0.1% -> 1% ROE at 10x leverage - PnL = 50 * 0.01 = $0.50 + const currentPrice = 49950; + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_partial'); + }); +}); + +// ============================================================================= +// PROFIT LOCK STOP TESTS +// ============================================================================= + +describe('Profit Lock Stop', () => { + test('should activate profit lock at 0.3% ROE', () => { + const position = createTestPosition({ + entryPrice: 50000, + breakEvenActivated: false, + }); + + // 0.3% ROE at 10x = 0.03% price move + const currentPrice = 50015; // ~0.3% ROE + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.position.breakEvenActivated).toBe(true); + expect(result.position.breakEvenStopPrice).toBeDefined(); + }); + + test('should calculate correct profit lock price for long position', () => { + const position = createTestPosition({ + side: 'long', + entryPrice: 50000, + leverage: 10, + breakEvenActivated: false, + }); + + const currentPrice = 50015; // Activate profit lock + + const result = updatePosition(position, currentPrice, defaultConfig); + + // Locked profit ROE is 0.2% + // Price change = (0.2 / 10 / 100) * 50000 = $10 + // Stop price = 50000 + 10 = 50010 + expect(result.position.breakEvenStopPrice).toBeCloseTo(50010, 0); + }); + + test('should calculate correct profit lock price for short position', () => { + const position = createTestPosition({ + side: 'short', + entryPrice: 50000, + leverage: 10, + breakEvenActivated: false, + }); + + const currentPrice = 49985; // Activate profit lock + + const result = updatePosition(position, currentPrice, defaultConfig); + + // Locked profit ROE is 0.2% + // Price change = (0.2 / 10 / 100) * 50000 = $10 + // Stop price = 50000 - 10 = 49990 + expect(result.position.breakEvenStopPrice).toBeCloseTo(49990, 0); + }); + + test('should trigger profit lock exit when hit (long)', () => { + const position = createTestPosition({ + side: 'long', + entryPrice: 50000, + leverage: 10, + breakEvenActivated: true, + breakEvenStopPrice: 50010, + highestROE: 0.5, + }); + + // Price drops to stop level + const currentPrice = 50010; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_trailing'); + expect(result.reason).toContain('Profit lock hit'); + }); + + test('should trigger profit lock exit when hit (short)', () => { + const position = createTestPosition({ + side: 'short', + entryPrice: 50000, + leverage: 10, + breakEvenActivated: true, + breakEvenStopPrice: 49990, + highestROE: 0.5, + }); + + // Price rises to stop level + const currentPrice = 49990; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_trailing'); + expect(result.reason).toContain('Profit lock hit'); + }); + + test('should not trigger profit lock if trailing stop is active', () => { + const position = createTestPosition({ + side: 'long', + entryPrice: 50000, + breakEvenActivated: true, + breakEvenStopPrice: 50010, + trailingActivated: true, // Trailing takes priority + }); + + const currentPrice = 50010; + + const result = updatePosition(position, currentPrice, defaultConfig); + + // Should not use profit lock if trailing is active + if (result.reason) { + expect(result.reason).not.toContain('Profit lock'); + } + }); +}); + +// ============================================================================= +// PEAK PROTECTION TESTS +// ============================================================================= + +describe('Peak Protection', () => { + test('should trigger when reversing 0.5% from peak above 1.0% ROE', () => { + const position = createTestPosition({ + entryPrice: 50000, + highestROE: 1.2, // Hit 1.2% peak + lowestROE: 0, + }); + + // Now at 0.6% ROE (reversed 0.6% from peak) + const currentPrice = 50030; // 0.6% ROE + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_trailing'); + expect(result.reason).toContain('Peak protection'); + expect(result.reason).toContain('1.20%'); // Peak + }); + + test('should not trigger if reversal is less than 0.5% from high peak', () => { + const position = createTestPosition({ + entryPrice: 50000, + highestROE: 1.2, + lowestROE: 0, + }); + + // At 0.8% ROE (only 0.4% reversal) + const currentPrice = 50040; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).not.toBe('close_trailing'); + }); + + test('should trigger when reversing 0.3% from small peak (0.3-1.0%)', () => { + const position = createTestPosition({ + entryPrice: 50000, + highestROE: 0.8, // Small peak + lowestROE: 0, + }); + + // Now at 0.4% ROE (reversed 0.4% from peak) + const currentPrice = 50020; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_trailing'); + expect(result.reason).toContain('Peak protection'); + expect(result.reason).toContain('0.80%'); // Peak + }); + + test('should not trigger for small peak with small reversal', () => { + const position = createTestPosition({ + entryPrice: 50000, + highestROE: 0.8, + lowestROE: 0, + }); + + // At 0.6% ROE (only 0.2% reversal) + const currentPrice = 50030; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).not.toBe('close_trailing'); + }); + + test('should not trigger for very small peaks (<0.3%)', () => { + const position = createTestPosition({ + entryPrice: 50000, + highestROE: 0.25, // Very small peak + lowestROE: 0, + }); + + // Now at -0.1% ROE (reversed 0.35%) + const currentPrice = 49995; + + const result = updatePosition(position, currentPrice, defaultConfig); + + // Should not use peak protection for such small peaks + if (result.reason) { + expect(result.reason).not.toContain('Peak protection'); + } + expect(result.action).not.toBe('close_trailing'); // Peak protection uses close_trailing + }); + + test('should work for short positions', () => { + const position = createTestPosition({ + side: 'short', + entryPrice: 50000, + highestROE: 1.5, + lowestROE: 0, + }); + + // Now at 0.9% ROE (reversed 0.6% from 1.5%) + const currentPrice = 49955; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_trailing'); + expect(result.reason).toContain('Peak protection'); + }); + + test('should track highest ROE correctly', () => { + let position = createTestPosition({ + entryPrice: 50000, + highestROE: 0, + }); + + // First update: 0.5% ROE + let result = updatePosition(position, 50025, defaultConfig); + expect(result.position.highestROE).toBeCloseTo(0.5, 1); + + // Second update: 1.0% ROE (new peak) + result = updatePosition(result.position, 50050, defaultConfig); + expect(result.position.highestROE).toBeCloseTo(1.0, 1); + + // Third update: 0.8% ROE (below peak) + result = updatePosition(result.position, 50040, defaultConfig); + expect(result.position.highestROE).toBeCloseTo(1.0, 1); // Peak unchanged + }); +}); + +// ============================================================================= +// BREAK-EVEN STOP TESTS +// ============================================================================= + +describe('Break-Even Stop', () => { + test('should activate break-even stop at 0.3% ROE', () => { + const position = createTestPosition({ + entryPrice: 50000, + breakEvenActivated: false, + }); + + const currentPrice = 50015; // 0.3% ROE + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.position.breakEvenActivated).toBe(true); + // Profit lock sets stop to guarantee 0.2% ROE profit, not break-even + expect(result.position.breakEvenStopPrice).toBeGreaterThan(50000); + }); + + test('should not trigger exit if not near break-even', () => { + const position = createTestPosition({ + entryPrice: 50000, + breakEvenActivated: true, + breakEvenStopPrice: 50000, + }); + + const currentPrice = 50020; // Still in profit + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('hold'); + }); + + test('should trigger exit when returning to break-even', () => { + const position = createTestPosition({ + entryPrice: 50000, + breakEvenActivated: true, + breakEvenStopPrice: 50000, + }); + + const currentPrice = 50002; // Very close to break-even, ROE < 0.1% + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_trailing'); + expect(result.reason).toContain('Break-even exit'); + }); + + test('should not trigger if ROE is above 0.1%', () => { + const position = createTestPosition({ + entryPrice: 50000, + breakEvenActivated: true, + breakEvenStopPrice: 50000, + }); + + const currentPrice = 50010; // ROE > 0.1% + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).not.toBe('close_trailing'); + }); +}); + +// ============================================================================= +// TIME-BASED EXIT TESTS +// ============================================================================= + +describe('Time-Based Exit', () => { + test('should trigger time exit for unprofitable position after 5 minutes', () => { + const openedAt = Date.now() - 6 * 60 * 1000; // 6 minutes ago + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + }); + + const currentPrice = 50005; // Only 0.1% ROE + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_time'); + expect(result.reason).toContain('Time exit'); + expect(result.reason).toContain('6.0min'); + }); + + test('should not trigger time exit if position is profitable', () => { + const openedAt = Date.now() - 6 * 60 * 1000; // 6 minutes ago + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + }); + + const currentPrice = 50020; // 0.4% ROE, profitable + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).not.toBe('close_time'); + }); + + test('should not trigger time exit before 5 minutes', () => { + const openedAt = Date.now() - 3 * 60 * 1000; // 3 minutes ago + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + }); + + const currentPrice = 50005; // Unprofitable + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('hold'); + }); + + test('should trigger max hold time regardless of profitability', () => { + const config = { ...defaultConfig, maxHoldTimeMinutes: 5 }; + const openedAt = Date.now() - 6 * 60 * 1000; // 6 minutes ago (past max) + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + }); + + const currentPrice = 50030; // Profitable + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_time'); + expect(result.reason).toContain('Max hold time'); + }); + + test('should trigger exactly at max hold time', () => { + const config = { ...defaultConfig, maxHoldTimeMinutes: 5 }; + const openedAt = Date.now() - 5 * 60 * 1000; // Exactly 5 minutes ago + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + }); + + const currentPrice = 50030; + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_time'); + }); +}); + +// ============================================================================= +// EXIT PRIORITY TESTS +// ============================================================================= + +describe('Exit Condition Priority', () => { + test('should prioritize stop loss over all other exits', () => { + const openedAt = Date.now() - 10 * 60 * 1000; // Old position + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + highestROE: 1.0, + breakEvenActivated: true, + }); + + // Price way down (stop loss triggered) + const currentPrice = 49800; + + const result = updatePosition(position, currentPrice, defaultConfig); + + expect(result.action).toBe('close_sl'); + expect(result.reason).toContain('stop loss'); + }); + + test('should prioritize take profit over time/trailing', () => { + const config = { ...defaultConfig, minProfitUSD: 0.05 }; + const openedAt = Date.now() - 10 * 60 * 1000; + const position = createTestPosition({ + entryPrice: 50000, + openedAt, + trailingActivated: true, + }); + + // At take profit level + const currentPrice = 50075; // 1.5% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_tp'); + }); + + test('should check partial profit before full TP', () => { + const config = { + ...defaultConfig, + partialProfitEnabled: true, + partialProfitROE: 1.0, + takeProfitROE: 1.5, + minProfitUSD: 0.05, // Lower threshold for test + }; + const position = createTestPosition({ + entryPrice: 50000, + originalSize: 0.001, + partialProfitTaken: false, + }); + + // Between partial and full TP + const currentPrice = 50055; // ~1.1% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_partial'); + }); + + test('should check trailing stop after profit lock', () => { + const position = createTestPosition({ + entryPrice: 50000, + breakEvenActivated: true, + breakEvenStopPrice: 50010, + trailingActivated: true, // Both active + trailingStopPrice: 50030, + }); + + // Price at trailing stop level + const currentPrice = 50030; + + const result = updatePosition(position, currentPrice, defaultConfig); + + // Trailing takes priority when both active + expect(result.action).toBe('close_trailing'); + expect(result.reason).not.toContain('Profit lock'); + }); +}); + +// ============================================================================= +// DYNAMIC TAKE PROFIT TESTS +// ============================================================================= + +describe('Dynamic Take Profit', () => { + test('should use dynamic TP when available', () => { + const config = { ...defaultConfig, takeProfitROE: 1.5 }; + const position = createTestPosition({ + entryPrice: 50000, + dynamicTP: 2.0, // Higher than standard + }); + + // At standard TP but below dynamic TP + const currentPrice = 50075; // 1.5% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('hold'); // Not at dynamic TP yet + }); + + test('should trigger when dynamic TP is reached', () => { + const config = { ...defaultConfig, takeProfitROE: 1.5, minProfitUSD: 0.05 }; + const position = createTestPosition({ + entryPrice: 50000, + dynamicTP: 2.0, + }); + + // At dynamic TP + const currentPrice = 50100; // 2.0% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_tp'); + expect(result.reason).toContain('2.0%'); // Dynamic TP + }); + + test('should fall back to standard TP when no dynamic TP', () => { + const config = { ...defaultConfig, minProfitUSD: 0.05 }; + const position = createTestPosition({ + entryPrice: 50000, + dynamicTP: undefined, + }); + + // At standard TP + const currentPrice = 50075; // 1.5% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('close_tp'); + }); + + test('should use position-level TP over config TP', () => { + const config = { ...defaultConfig, takeProfitROE: 1.5 }; + const position = createTestPosition({ + entryPrice: 50000, + takeProfit: 2.5, // Position-specific TP + }); + + // At config TP but below position TP + const currentPrice = 50075; // 1.5% ROE + + const result = updatePosition(position, currentPrice, config); + + expect(result.action).toBe('hold'); // Not at 2.5% yet + }); +}); diff --git a/tests/unit/multi-exchange-position-manager.test.ts b/tests/unit/multi-exchange-position-manager.test.ts new file mode 100644 index 0000000..706611f --- /dev/null +++ b/tests/unit/multi-exchange-position-manager.test.ts @@ -0,0 +1,791 @@ +/** + * Multi-Exchange Position Manager Unit Tests + * Tests for risk management functions across multiple exchanges + */ + +// Mock the multi-exchange executor before importing the position manager +jest.mock('../../src/services/execution/multi-exchange-executor', () => ({ + multiExchangeExecutor: { + getTotalBalance: jest.fn().mockResolvedValue({ balance: 10000, currency: 'USD' }), + initialize: jest.fn().mockResolvedValue(undefined), + }, +})); + +import { + MultiExchangePositionManager, + UnifiedPosition, +} from '../../src/services/position/multi-exchange-position-manager'; +import type { ScalperConfig } from '../../src/types'; +import { ExchangeType } from '../../src/services/execution/exchange-abstraction'; + +// ============================================================================= +// TEST DATA +// ============================================================================= + +const defaultConfig: ScalperConfig = { + leverage: 10, + positionSizePercent: 35, + positionSizeUSD: null, + minPositionSizeUSD: 10, + maxPositionSizeUSD: 150, + maxExposurePercent: 80, + maxPositions: 20, + riskPerTradePercent: 2, + maxDailyLossPercent: 10, + maxDrawdownPercent: 20, + dailyProfitTargetPercent: 0, + tickIntervalMs: 15000, + scanIntervalTicks: 2, + maxHoldTimeMinutes: 5, + takeProfitROE: 1.5, + stopLossROE: -0.4, + minProfitUSD: 0.2, + trailingActivationROE: 0.5, + trailingDistanceROE: 0.2, + minIvishXConfidence: 5, + minCombinedConfidence: 55, + requireLLMAgreement: false, + minConfidenceWithoutLLM: 50, + minScoreForSignal: 50, + rsiPeriod: 14, + rsiOversold: 35, + rsiOverbought: 65, + momentumPeriod: 3, + minMomentum: 0.2, + maxMomentum: 3.0, + volumePeriod: 20, + minVolumeRatio: 0.3, + trendSMAFast: 10, + trendSMASlow: 20, + klineInterval: '5m', + klineCount: 60, + llmEnabled: false, + llmConfidenceBoost: 15, + llmExitAnalysisEnabled: false, + llmExitAnalysisMinutes: 2, + llmExitConfidenceThreshold: 80, + bounceDetectionEnabled: true, + bounceRSIThreshold: 35, + bounceStochThreshold: 25, + bounceWilliamsThreshold: -75, + bounceMinGreenCandles: 2, + bounceBonusPoints: 20, +}; + +function createMockPosition(overrides: Partial = {}): UnifiedPosition { + return { + id: 'test-pos-1', + exchange: 'aster', + symbol: 'BTCUSDT', + side: 'long', + size: 0.001, + entryPrice: 50000, + currentPrice: 50000, + leverage: 10, + marginUsed: 5, // 0.001 * 50000 / 10 = $5 + unrealizedPnl: 0, + unrealizedROE: 0, + highestROE: 0, + lowestROE: 0, + stopLoss: null, + takeProfit: null, + openedAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; +} + +// ============================================================================= +// MONITOR POSITION TESTS +// ============================================================================= + +describe('MultiExchangePositionManager.monitorPosition', () => { + let manager: MultiExchangePositionManager; + + beforeEach(() => { + manager = new MultiExchangePositionManager(); + }); + + describe('Stop Loss Checks', () => { + test('should trigger close when stop loss is hit', async () => { + const position = createMockPosition({ + unrealizedROE: -0.5, // Below -0.4% stop loss + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.shouldClose).toBe(true); + expect(result.reason).toContain('Stop-loss hit'); + expect(result.reason).toContain('-0.50%'); + }); + + test('should not trigger close when above stop loss', async () => { + const position = createMockPosition({ + unrealizedROE: -0.3, // Above -0.4% stop loss + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).not.toBe('close'); + expect(result.shouldClose).not.toBe(true); + }); + + test('should trigger exactly at stop loss threshold', async () => { + const position = createMockPosition({ + unrealizedROE: -0.4, // Exactly at stop loss + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.shouldClose).toBe(true); + }); + + test('should handle different stop loss configurations', async () => { + const config = { ...defaultConfig, stopLossROE: -1.0 }; + const position = createMockPosition({ + unrealizedROE: -0.5, + }); + + const result = await manager.monitorPosition(position, config); + + expect(result.action).not.toBe('close'); + expect(result.shouldClose).not.toBe(true); + }); + }); + + describe('Take Profit Checks', () => { + test('should trigger close when take profit is hit', async () => { + const position = createMockPosition({ + unrealizedROE: 1.6, // Above 1.5% take profit + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.shouldClose).toBe(true); + expect(result.reason).toContain('Take-profit hit'); + expect(result.reason).toContain('1.60%'); + }); + + test('should not trigger close when below take profit', async () => { + const position = createMockPosition({ + unrealizedROE: 1.0, // Below 1.5% take profit + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).not.toBe('close'); + expect(result.shouldClose).not.toBe(true); + }); + + test('should use custom take profit from position', async () => { + const position = createMockPosition({ + unrealizedROE: 2.0, + takeProfit: 2.5, // Custom TP + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).not.toBe('close'); + expect(result.shouldClose).not.toBe(true); + }); + + test('should trigger with custom take profit', async () => { + const position = createMockPosition({ + unrealizedROE: 3.0, + takeProfit: 2.5, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.shouldClose).toBe(true); + }); + }); + + describe('Max Hold Time Checks', () => { + test('should trigger close when max hold time exceeded', async () => { + const openedAt = Date.now() - 6 * 60 * 1000; // 6 minutes ago + const position = createMockPosition({ + unrealizedROE: 0.5, + openedAt, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.shouldClose).toBe(true); + expect(result.reason).toContain('Max hold time exceeded'); + expect(result.reason).toContain('6.0 minutes'); + }); + + test('should not trigger close when under max hold time', async () => { + const openedAt = Date.now() - 3 * 60 * 1000; // 3 minutes ago + const position = createMockPosition({ + unrealizedROE: 0.5, + openedAt, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).not.toBe('close'); + expect(result.shouldClose).not.toBe(true); + }); + + test('should trigger exactly at max hold time', async () => { + const openedAt = Date.now() - 5 * 60 * 1000; // Exactly 5 minutes ago + const position = createMockPosition({ + unrealizedROE: 0.5, + openedAt, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.shouldClose).toBe(true); + }); + + test('should handle different max hold time configurations', async () => { + const config = { ...defaultConfig, maxHoldTimeMinutes: 10 }; + const openedAt = Date.now() - 6 * 60 * 1000; // 6 minutes ago + const position = createMockPosition({ + unrealizedROE: 0.5, + openedAt, + }); + + const result = await manager.monitorPosition(position, config); + + expect(result.action).not.toBe('close'); + expect(result.shouldClose).not.toBe(true); + }); + }); + + describe('Trailing Stop Checks', () => { + test('should activate trailing stop when ROE exceeds activation threshold', async () => { + const position = createMockPosition({ + unrealizedROE: 0.6, // Above 0.5% activation + currentPrice: 50500, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('update_trailing'); + expect(result.reason).toContain('Trailing stop activated'); + expect(result.reason).toContain('0.60%'); + expect(result.newStopLoss).toBeDefined(); + }); + + test('should not activate trailing stop when below activation threshold', async () => { + const position = createMockPosition({ + unrealizedROE: 0.4, // Below 0.5% activation + currentPrice: 50200, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + expect(result.newStopLoss).toBeUndefined(); + }); + + test('should calculate trailing stop price correctly', async () => { + const currentPrice = 50000; + const position = createMockPosition({ + unrealizedROE: 0.6, + currentPrice, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + // Trailing distance is 0.2%, so stop should be 0.2% below current price + const expectedStop = currentPrice * (1 - 0.2 / 100); + expect(result.newStopLoss).toBeCloseTo(expectedStop, 2); + }); + + test('should use custom trailing distance if configured', async () => { + const config = { ...defaultConfig, trailingDistanceROE: 0.5 }; + const currentPrice = 50000; + const position = createMockPosition({ + unrealizedROE: 0.6, + currentPrice, + }); + + const result = await manager.monitorPosition(position, config); + + // Trailing distance is 0.5% + const expectedStop = currentPrice * (1 - 0.5 / 100); + expect(result.newStopLoss).toBeCloseTo(expectedStop, 2); + }); + }); + + describe('Multiple Conditions Priority', () => { + test('should prioritize stop loss over take profit', async () => { + const position = createMockPosition({ + unrealizedROE: -0.5, // Both SL and should not be TP + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.reason).toContain('Stop-loss'); + }); + + test('should check stop loss before max hold time', async () => { + const openedAt = Date.now() - 10 * 60 * 1000; // 10 minutes ago + const position = createMockPosition({ + unrealizedROE: -0.5, // Stop loss triggered + openedAt, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.reason).toContain('Stop-loss'); + }); + + test('should check take profit before max hold time', async () => { + const openedAt = Date.now() - 10 * 60 * 1000; // 10 minutes ago + const position = createMockPosition({ + unrealizedROE: 2.0, // Take profit triggered + openedAt, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.reason).toContain('Take-profit'); + }); + + test('should check trailing stop after other exit conditions', async () => { + const position = createMockPosition({ + unrealizedROE: 0.6, // Trailing stop activation + currentPrice: 50500, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('update_trailing'); + }); + }); + + describe('Hold Conditions', () => { + test('should return hold when no exit conditions are met', async () => { + const position = createMockPosition({ + unrealizedROE: 0.3, // Within acceptable range + openedAt: Date.now() - 2 * 60 * 1000, // 2 minutes + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + expect(result.shouldClose).toBeUndefined(); + expect(result.reason).toContain('acceptable parameters'); + }); + + test('should hold when ROE is near zero', async () => { + const position = createMockPosition({ + unrealizedROE: 0.0, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + }); + + test('should hold when in small profit', async () => { + const position = createMockPosition({ + unrealizedROE: 0.2, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + }); + + test('should hold when in small loss', async () => { + const position = createMockPosition({ + unrealizedROE: -0.1, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + }); + }); + + describe('Edge Cases', () => { + test('should handle position with zero ROE', async () => { + const position = createMockPosition({ + unrealizedROE: 0, + unrealizedPnl: 0, + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + }); + + test('should handle position opened just now', async () => { + const position = createMockPosition({ + unrealizedROE: 0.3, + openedAt: Date.now(), + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('hold'); + }); + + test('should handle very high ROE', async () => { + const position = createMockPosition({ + unrealizedROE: 10.0, // 10% ROE + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.reason).toContain('Take-profit'); + }); + + test('should handle very low ROE', async () => { + const position = createMockPosition({ + unrealizedROE: -5.0, // -5% ROE + }); + + const result = await manager.monitorPosition(position, defaultConfig); + + expect(result.action).toBe('close'); + expect(result.reason).toContain('Stop-loss'); + }); + }); +}); + +// ============================================================================= +// POSITION CONVERSION TESTS +// ============================================================================= + +describe('MultiExchangePositionManager Position Conversion', () => { + let manager: MultiExchangePositionManager; + + beforeEach(() => { + manager = new MultiExchangePositionManager(); + }); + + test('should convert position with standard format', () => { + const rawPosition = { + symbol: 'BTCUSDT', + size: '0.001', + entry_price: '50000', + mark_price: '51000', + unrealized_pnl: '1', + side: 'long', + margin: '5', + leverage: '10', + }; + + // Access private method via any cast for testing + const unified = (manager as any).convertToUnifiedPosition(rawPosition, 'aster'); + + expect(unified).toBeDefined(); + expect(unified.symbol).toBe('BTCUSDT'); + expect(unified.side).toBe('long'); + expect(unified.size).toBe(0.001); + expect(unified.entryPrice).toBe(50000); + expect(unified.currentPrice).toBe(51000); + expect(unified.unrealizedPnl).toBe(1); + expect(unified.marginUsed).toBe(5); + expect(unified.leverage).toBe(10); + }); + + test('should convert position with alternative field names', () => { + const rawPosition = { + market: 'ETHUSDT', + positionAmt: '0.5', + entryPrice: '2000', + currentPrice: '2100', + unrealizedProfit: '50', + marginUsed: '100', + leverage: '20', + }; + + const unified = (manager as any).convertToUnifiedPosition(rawPosition, 'paradex'); + + expect(unified).toBeDefined(); + expect(unified.symbol).toBe('ETHUSDT'); + expect(unified.size).toBe(0.5); + expect(unified.entryPrice).toBe(2000); + expect(unified.currentPrice).toBe(2100); + }); + + test('should calculate ROE correctly', () => { + const rawPosition = { + symbol: 'BTCUSDT', + size: '0.001', + entry_price: '50000', + mark_price: '51000', + unrealized_pnl: '1', + side: 'long', + margin: '5', + leverage: '10', + }; + + const unified = (manager as any).convertToUnifiedPosition(rawPosition, 'aster'); + + // ROE = (unrealizedPnl / margin) * 100 = (1 / 5) * 100 = 20% + expect(unified.unrealizedROE).toBeCloseTo(20, 2); + }); + + test('should determine side from positionAmt', () => { + const longPosition = { + symbol: 'BTCUSDT', + positionAmt: '0.5', + entry_price: '50000', + mark_price: '51000', + unrealized_pnl: '500', + margin: '2500', + leverage: '10', + }; + + const shortPosition = { + symbol: 'BTCUSDT', + positionAmt: '-0.5', + entry_price: '50000', + mark_price: '49000', + unrealized_pnl: '500', + margin: '2500', + leverage: '10', + }; + + const long = (manager as any).convertToUnifiedPosition(longPosition, 'binance'); + const short = (manager as any).convertToUnifiedPosition(shortPosition, 'binance'); + + expect(long.side).toBe('long'); + expect(short.side).toBe('short'); + }); + + test('should return null for zero size positions', () => { + const rawPosition = { + symbol: 'BTCUSDT', + size: '0', + entry_price: '50000', + mark_price: '50000', + unrealized_pnl: '0', + side: 'long', + margin: '0', + leverage: '10', + }; + + const unified = (manager as any).convertToUnifiedPosition(rawPosition, 'aster'); + + expect(unified).toBeNull(); + }); + + test('should handle missing optional fields', () => { + const rawPosition = { + symbol: 'BTCUSDT', + size: '0.001', + entry_price: '50000', + mark_price: '51000', + unrealized_pnl: '1', + side: 'long', + margin: '5', + }; + + const unified = (manager as any).convertToUnifiedPosition(rawPosition, 'aster'); + + expect(unified).toBeDefined(); + expect(unified.leverage).toBe(1); // Default leverage + }); + + test('should handle liquidation price if present', () => { + const rawPosition = { + symbol: 'BTCUSDT', + size: '0.001', + entry_price: '50000', + mark_price: '51000', + unrealized_pnl: '1', + side: 'long', + margin: '5', + leverage: '10', + liquidation_price: '45000', + }; + + const unified = (manager as any).convertToUnifiedPosition(rawPosition, 'aster'); + + expect(unified.liquidationPrice).toBe(45000); + }); +}); + +// ============================================================================= +// EXPOSURE CALCULATION TESTS +// ============================================================================= + +describe('MultiExchangePositionManager.canOpenNewPosition', () => { + let manager: MultiExchangePositionManager; + + beforeEach(() => { + manager = new MultiExchangePositionManager(); + + // Mock the getAllUnifiedPositions method + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue([]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should allow opening when under position limit', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ id: 'pos1' }), + createMockPosition({ id: 'pos2' }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const result = await manager.canOpenNewPosition(defaultConfig, 100); + + expect(result.canOpen).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + test('should reject when at max positions', async () => { + const config = { ...defaultConfig, maxPositions: 2 }; + const positions: UnifiedPosition[] = [ + createMockPosition({ id: 'pos1' }), + createMockPosition({ id: 'pos2' }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const result = await manager.canOpenNewPosition(config, 100); + + expect(result.canOpen).toBe(false); + expect(result.reason).toContain('Maximum positions reached'); + expect(result.reason).toContain('2/2'); + }); + + test('should allow opening with zero current positions', async () => { + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue([]); + + const result = await manager.canOpenNewPosition(defaultConfig, 100); + + expect(result.canOpen).toBe(true); + }); +}); + +// ============================================================================= +// POSITION SUMMARY TESTS +// ============================================================================= + +describe('MultiExchangePositionManager.getPositionSummary', () => { + let manager: MultiExchangePositionManager; + + beforeEach(() => { + manager = new MultiExchangePositionManager(); + }); + + test('should return empty summary when no positions', async () => { + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue([]); + + const summary = await manager.getPositionSummary(); + + expect(summary.totalPositions).toBe(0); + expect(summary.byExchange.size).toBe(0); + expect(summary.totalUnrealizedPnL).toBe(0); + expect(summary.totalMarginUsed).toBe(0); + }); + + test('should count positions correctly', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ id: 'pos1', exchange: 'aster' }), + createMockPosition({ id: 'pos2', exchange: 'aster' }), + createMockPosition({ id: 'pos3', exchange: 'paradex' }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const summary = await manager.getPositionSummary(); + + expect(summary.totalPositions).toBe(3); + expect(summary.byExchange.get('aster')).toBe(2); + expect(summary.byExchange.get('paradex')).toBe(1); + }); + + test('should sum PnL correctly', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ unrealizedPnl: 10 }), + createMockPosition({ unrealizedPnl: -5 }), + createMockPosition({ unrealizedPnl: 15 }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const summary = await manager.getPositionSummary(); + + expect(summary.totalUnrealizedPnL).toBe(20); + }); + + test('should sum margin correctly', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ marginUsed: 50 }), + createMockPosition({ marginUsed: 30 }), + createMockPosition({ marginUsed: 20 }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const summary = await manager.getPositionSummary(); + + expect(summary.totalMarginUsed).toBe(100); + }); +}); + +// ============================================================================= +// CALCULATE TOTAL EXPOSURE TESTS +// ============================================================================= + +describe('MultiExchangePositionManager.calculateTotalExposure', () => { + let manager: MultiExchangePositionManager; + + beforeEach(() => { + manager = new MultiExchangePositionManager(); + }); + + test('should return 0 for no positions', async () => { + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue([]); + + const exposure = await manager.calculateTotalExposure(); + + expect(exposure).toBe(0); + }); + + test('should calculate exposure from single position', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ size: 0.001, currentPrice: 50000 }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const exposure = await manager.calculateTotalExposure(); + + // 0.001 * 50000 = 50 + expect(exposure).toBe(50); + }); + + test('should sum exposure from multiple positions', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ size: 0.001, currentPrice: 50000 }), // 50 + createMockPosition({ size: 1.0, currentPrice: 2000 }), // 2000 + createMockPosition({ size: 0.5, currentPrice: 100 }), // 50 + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const exposure = await manager.calculateTotalExposure(); + + expect(exposure).toBe(2100); + }); + + test('should handle fractional sizes and prices', async () => { + const positions: UnifiedPosition[] = [ + createMockPosition({ size: 0.0125, currentPrice: 49750.50 }), + ]; + jest.spyOn(manager, 'getAllUnifiedPositions').mockResolvedValue(positions); + + const exposure = await manager.calculateTotalExposure(); + + expect(exposure).toBeCloseTo(621.88, 2); + }); +}); diff --git a/tests/unit/order-executor.test.ts b/tests/unit/order-executor.test.ts new file mode 100644 index 0000000..2a78e09 --- /dev/null +++ b/tests/unit/order-executor.test.ts @@ -0,0 +1,810 @@ +/** + * Order Executor Unit Tests + * Tests for risk management functions in the order executor + */ + +import { + calculatePositionSize, + calculateExposure, + canOpenPosition, +} from '../../src/services/execution/order-executor'; +import type { Position, ScalperConfig } from '../../src/types'; + +// ============================================================================= +// TEST DATA +// ============================================================================= + +const defaultConfig: ScalperConfig = { + leverage: 10, + positionSizePercent: 35, + positionSizeUSD: null, + minPositionSizeUSD: 10, + maxPositionSizeUSD: 150, + maxExposurePercent: 80, + maxPositions: 20, + riskPerTradePercent: 2, + maxDailyLossPercent: 10, + maxDrawdownPercent: 20, + dailyProfitTargetPercent: 0, + tickIntervalMs: 15000, + scanIntervalTicks: 2, + maxHoldTimeMinutes: 5, + takeProfitROE: 1.5, + stopLossROE: -0.4, + minProfitUSD: 0.2, + trailingActivationROE: 0.5, + trailingDistanceROE: 0.2, + minIvishXConfidence: 5, + minCombinedConfidence: 55, + requireLLMAgreement: false, + minConfidenceWithoutLLM: 50, + minScoreForSignal: 50, + rsiPeriod: 14, + rsiOversold: 35, + rsiOverbought: 65, + momentumPeriod: 3, + minMomentum: 0.2, + maxMomentum: 3.0, + volumePeriod: 20, + minVolumeRatio: 0.3, + trendSMAFast: 10, + trendSMASlow: 20, + klineInterval: '5m', + klineCount: 60, + llmEnabled: false, + llmConfidenceBoost: 15, + llmExitAnalysisEnabled: false, + llmExitAnalysisMinutes: 2, + llmExitConfidenceThreshold: 80, + bounceDetectionEnabled: true, + bounceRSIThreshold: 35, + bounceStochThreshold: 25, + bounceWilliamsThreshold: -75, + bounceMinGreenCandles: 2, + bounceBonusPoints: 20, + dynamicPositionSizing: true, + maxPositionSizeBoost: 1.5, + minPositionSizeReduction: 0.7, + performanceAdaptation: true, + highWinRateThreshold: 0.65, +}; + +function createTestPosition( + symbol: string, + marginUsed: number, + overrides: Partial = {} +): Position { + return { + id: `test-pos-${symbol}`, + agentId: 'test-agent', + userId: 'test-user', + symbol, + side: 'long', + size: 0.001, + entryPrice: 50000, + currentPrice: 50000, + leverage: 10, + marginUsed, + unrealizedPnl: 0, + unrealizedROE: 0, + highestROE: 0, + lowestROE: 0, + stopLoss: null, + takeProfit: null, + trailingActivated: false, + trailingStopPrice: null, + ivishxConfidence: 7, + llmConfidence: 65, + entryReason: ['Test entry'], + openedAt: Date.now(), + updatedAt: Date.now(), + maxHoldTime: 5 * 60 * 1000, + ...overrides, + }; +} + +// ============================================================================= +// CALCULATE POSITION SIZE TESTS +// ============================================================================= + +describe('calculatePositionSize', () => { + describe('Fixed Position Size', () => { + test('should use fixed position size when configured', () => { + const config = { ...defaultConfig, positionSizeUSD: 50 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + + const quantity = calculatePositionSize(equity, currentExposure, price, config); + + // $50 / $50000 = 0.001 BTC + expect(quantity).toBeCloseTo(0.001, 8); + }); + + test('should apply min constraint to fixed size', () => { + const config = { ...defaultConfig, positionSizeUSD: 5, minPositionSizeUSD: 10 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + + const quantity = calculatePositionSize(equity, currentExposure, price, config); + + // Should use minPositionSizeUSD of $10 + expect(quantity).toBeCloseTo(0.0002, 8); + }); + + test('should apply max constraint to fixed size', () => { + const config = { ...defaultConfig, positionSizeUSD: 200, maxPositionSizeUSD: 150 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + + const quantity = calculatePositionSize(equity, currentExposure, price, config); + + // Should use maxPositionSizeUSD of $150 + expect(quantity).toBeCloseTo(0.003, 8); + }); + }); + + describe('Percentage-Based Position Size', () => { + test('should calculate position size based on percentage of available balance', () => { + const equity = 1000; + const currentExposure = 0; + const price = 50000; + + const quantity = calculatePositionSize(equity, currentExposure, price, defaultConfig); + + // Available: $1000, 35% = $350, but capped at maxPositionSizeUSD $150 + // $150 / $50000 = 0.003 + expect(quantity).toBeCloseTo(0.003, 8); + }); + + test('should subtract current exposure from available balance', () => { + const equity = 1000; + const currentExposure = 600; // $600 already in positions + const price = 50000; + + const quantity = calculatePositionSize(equity, currentExposure, price, defaultConfig); + + // Available: $1000 - $600 = $400, 35% = $140 + expect(quantity).toBeCloseTo(0.0028, 8); + }); + + test('should handle zero available balance', () => { + const equity = 500; + const currentExposure = 500; + const price = 50000; + + const quantity = calculatePositionSize(equity, currentExposure, price, defaultConfig); + + // Available: $0, but should use minPositionSizeUSD + expect(quantity).toBeCloseTo(0.0002, 8); // $10 min / $50000 + }); + + test('should apply min and max constraints', () => { + const config = { ...defaultConfig, minPositionSizeUSD: 50, maxPositionSizeUSD: 100 }; + + // Test min constraint + const equity1 = 100; + const qty1 = calculatePositionSize(equity1, 0, 50000, config); + expect(qty1).toBeCloseTo(0.001, 8); // $50 min / $50000 + + // Test max constraint + const equity2 = 10000; + const qty2 = calculatePositionSize(equity2, 0, 50000, config); + expect(qty2).toBeCloseTo(0.002, 8); // $100 max / $50000 + }); + }); + + describe('Dynamic Position Sizing', () => { + test('should increase size for high confidence signals (70%+)', () => { + // Use low equity so we don't hit max constraint + const config = { ...defaultConfig, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const highConfidence = 75; + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + highConfidence + ); + + // Should boost position size + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + dynamicPositionSizing: false, + }); + expect(quantity).toBeGreaterThan(baseQuantity); + }); + + test('should decrease size for low confidence signals (<65%)', () => { + const config = { ...defaultConfig, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const lowConfidence = 58; + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + lowConfidence + ); + + // Should reduce position size + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + dynamicPositionSizing: false, + }); + expect(quantity).toBeLessThan(baseQuantity); + }); + + test('should not exceed max boost multiplier', () => { + const config = { ...defaultConfig, maxPositionSizeBoost: 1.5 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const veryHighConfidence = 95; + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + veryHighConfidence + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + dynamicPositionSizing: false, + }); + + // Should not exceed 1.5x boost (accounting for max constraint) + const ratio = quantity / baseQuantity; + expect(ratio).toBeLessThanOrEqual(1.51); // Small margin for floating point + }); + + test('should apply reduction multiplier for low confidence', () => { + const config = { ...defaultConfig, minPositionSizeReduction: 0.7, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const lowConfidence = 55; + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + lowConfidence + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + dynamicPositionSizing: false, + }); + + // Should reduce to ~0.7x + expect(quantity).toBeLessThan(baseQuantity); + }); + }); + + describe('Performance-Based Adaptation', () => { + test('should increase size for high win rate (>=65%)', () => { + const config = { ...defaultConfig, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const highWinRate = 0.7; // 70% win rate + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + undefined, + highWinRate + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + performanceAdaptation: false, + }); + + // Should increase by 15% + expect(quantity).toBeGreaterThan(baseQuantity); + }); + + test('should decrease size for low win rate (<40%)', () => { + const config = { ...defaultConfig, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const lowWinRate = 0.35; // 35% win rate + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + undefined, + lowWinRate + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + performanceAdaptation: false, + }); + + // Should decrease by 20% + expect(quantity).toBeLessThan(baseQuantity); + }); + + test('should not adjust for medium win rate (40-65%)', () => { + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const mediumWinRate = 0.55; // 55% win rate + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + defaultConfig, + undefined, + mediumWinRate + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...defaultConfig, + performanceAdaptation: false, + }); + + // Should be approximately equal + expect(quantity).toBeCloseTo(baseQuantity, 6); + }); + }); + + describe('Combined Dynamic and Performance Sizing', () => { + test('should combine confidence boost and win rate increase', () => { + const config = { ...defaultConfig, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const highConfidence = 80; + const highWinRate = 0.7; + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + highConfidence, + highWinRate + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + dynamicPositionSizing: false, + performanceAdaptation: false, + }); + + // Should be significantly larger + expect(quantity).toBeGreaterThan(baseQuantity); + }); + + test('should combine confidence reduction and win rate decrease', () => { + const config = { ...defaultConfig, maxPositionSizeUSD: 1000 }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + const lowConfidence = 55; + const lowWinRate = 0.35; + + const quantity = calculatePositionSize( + equity, + currentExposure, + price, + config, + lowConfidence, + lowWinRate + ); + + const baseQuantity = calculatePositionSize(equity, currentExposure, price, { + ...config, + dynamicPositionSizing: false, + performanceAdaptation: false, + }); + + // Should be significantly smaller + expect(quantity).toBeLessThan(baseQuantity); + }); + }); + + describe('Edge Cases', () => { + test('should handle very high price assets', () => { + const equity = 1000; + const currentExposure = 0; + const price = 100000; // $100k BTC + + const quantity = calculatePositionSize(equity, currentExposure, price, defaultConfig); + + // Should still calculate correctly + expect(quantity).toBeGreaterThan(0); + expect(quantity * price).toBeGreaterThanOrEqual(defaultConfig.minPositionSizeUSD); + }); + + test('should handle very low price assets', () => { + const equity = 1000; + const currentExposure = 0; + const price = 0.5; // $0.50 altcoin + + const quantity = calculatePositionSize(equity, currentExposure, price, defaultConfig); + + // Should still calculate correctly + expect(quantity).toBeGreaterThan(0); + expect(quantity * price).toBeGreaterThanOrEqual(defaultConfig.minPositionSizeUSD); + }); + + test('should handle disabled dynamic sizing', () => { + const config = { ...defaultConfig, dynamicPositionSizing: false }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + + const qty1 = calculatePositionSize(equity, currentExposure, price, config, 80); + const qty2 = calculatePositionSize(equity, currentExposure, price, config, 55); + + // Should be the same regardless of confidence + expect(qty1).toBeCloseTo(qty2, 8); + }); + + test('should handle disabled performance adaptation', () => { + const config = { ...defaultConfig, performanceAdaptation: false }; + const equity = 1000; + const currentExposure = 0; + const price = 50000; + + const qty1 = calculatePositionSize(equity, currentExposure, price, config, undefined, 0.7); + const qty2 = calculatePositionSize(equity, currentExposure, price, config, undefined, 0.35); + + // Should be the same regardless of win rate + expect(qty1).toBeCloseTo(qty2, 8); + }); + }); +}); + +// ============================================================================= +// CALCULATE EXPOSURE TESTS +// ============================================================================= + +describe('calculateExposure', () => { + test('should return 0 for no positions', () => { + const positions = new Map(); + const exposure = calculateExposure(positions); + expect(exposure).toBe(0); + }); + + test('should sum margin from single position', () => { + const positions = new Map(); + positions.set('pos1', createTestPosition('BTCUSDT', 50)); + + const exposure = calculateExposure(positions); + expect(exposure).toBe(50); + }); + + test('should sum margin from multiple positions', () => { + const positions = new Map(); + positions.set('pos1', createTestPosition('BTCUSDT', 50)); + positions.set('pos2', createTestPosition('ETHUSDT', 30)); + positions.set('pos3', createTestPosition('BNBUSDT', 20)); + + const exposure = calculateExposure(positions); + expect(exposure).toBe(100); + }); + + test('should handle positions with zero margin', () => { + const positions = new Map(); + positions.set('pos1', createTestPosition('BTCUSDT', 0)); + positions.set('pos2', createTestPosition('ETHUSDT', 50)); + + const exposure = calculateExposure(positions); + expect(exposure).toBe(50); + }); + + test('should handle large number of positions', () => { + const positions = new Map(); + for (let i = 0; i < 100; i++) { + positions.set(`pos${i}`, createTestPosition(`SYM${i}`, 10)); + } + + const exposure = calculateExposure(positions); + expect(exposure).toBe(1000); + }); + + test('should handle fractional margin values', () => { + const positions = new Map(); + positions.set('pos1', createTestPosition('BTCUSDT', 15.75)); + positions.set('pos2', createTestPosition('ETHUSDT', 23.33)); + + const exposure = calculateExposure(positions); + expect(exposure).toBeCloseTo(39.08, 2); + }); +}); + +// ============================================================================= +// CAN OPEN POSITION TESTS +// ============================================================================= + +describe('canOpenPosition', () => { + describe('Equity Checks', () => { + test('should allow opening with sufficient equity', () => { + const equity = 100; + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 10; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + test('should reject when equity below minimum ($10)', () => { + const equity = 9; + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 5; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Insufficient equity'); + expect(result.reason).toContain('$9.00 < $10'); + }); + + test('should allow exactly $10 equity', () => { + const config = { ...defaultConfig, maxExposurePercent: 100 }; // Allow full equity use + const equity = 10; + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 8; // 80% of $10 + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(true); + }); + }); + + describe('Position Count Checks', () => { + test('should allow opening when under max positions', () => { + const config = { ...defaultConfig, maxPositions: 5 }; + const equity = 1000; + const currentExposure = 100; + const positionCount = 4; + const estimatedMargin = 50; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(true); + }); + + test('should reject when at max positions', () => { + const config = { ...defaultConfig, maxPositions: 5 }; + const equity = 1000; + const currentExposure = 100; + const positionCount = 5; + const estimatedMargin = 50; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Max positions reached'); + expect(result.reason).toContain('5/5'); + }); + + test('should allow opening with 0 current positions', () => { + const equity = 1000; + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 50; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + }); + + describe('Exposure Limit Checks', () => { + test('should allow opening when under max exposure', () => { + const equity = 1000; + const currentExposure = 400; // 40% of equity + const positionCount = 2; + const estimatedMargin = 200; // Total would be 60% + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should reject when new position would exceed max exposure', () => { + const equity = 1000; + const currentExposure = 600; // 60% of equity + const positionCount = 3; + const estimatedMargin = 300; // Total would be 90%, exceeds 80% + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Exposure limit'); + expect(result.reason).toContain('$900.00 > $800.00'); + }); + + test('should allow opening exactly at max exposure', () => { + const equity = 1000; + const currentExposure = 400; + const positionCount = 2; + const estimatedMargin = 400; // Exactly 80% + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should calculate max exposure correctly with different percentages', () => { + const config = { ...defaultConfig, maxExposurePercent: 50 }; + const equity = 1000; + const currentExposure = 300; + const positionCount = 2; + const estimatedMargin = 250; // Total 55%, exceeds 50% + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain('$550.00 > $500.00'); + }); + + test('should reject when max exposure is too low', () => { + const config = { ...defaultConfig, maxExposurePercent: 50 }; + const equity = 15; // Max exposure = $7.50, below $10 minimum + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 5; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain('Max exposure too low'); + expect(result.reason).toContain('$7.50 < $10'); + }); + }); + + describe('Edge Cases', () => { + test('should handle zero current exposure', () => { + const equity = 1000; + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 100; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should handle zero estimated margin', () => { + const equity = 1000; + const currentExposure = 500; + const positionCount = 2; + const estimatedMargin = 0; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should handle very high equity', () => { + const equity = 1000000; + const currentExposure = 500000; + const positionCount = 10; + const estimatedMargin = 100000; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should handle fractional equity and margins', () => { + const equity = 125.75; + const currentExposure = 50.25; + const positionCount = 2; + const estimatedMargin = 30.10; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should reject when multiple conditions fail', () => { + const config = { ...defaultConfig, maxPositions: 5 }; + const equity = 100; + const currentExposure = 70; + const positionCount = 5; // At max positions + const estimatedMargin = 50; // Would exceed exposure + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(false); + // Should return first failure (max positions) + expect(result.reason).toContain('Max positions reached'); + }); + + test('should provide detailed error messages', () => { + const equity = 1000; + const currentExposure = 600; + const positionCount = 5; + const estimatedMargin = 300; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(false); + expect(result.reason).toBeDefined(); + expect(result.reason).toContain('Current: $600.00'); + expect(result.reason).toContain('New: $300.00'); + expect(result.reason).toContain('Max: $800.00'); + }); + }); + + describe('Boundary Conditions', () => { + test('should handle equity exactly at minimum with valid margin', () => { + const config = { ...defaultConfig, maxExposurePercent: 100 }; // Allow full equity use + const equity = 10; + const currentExposure = 0; + const positionCount = 0; + const estimatedMargin = 8; // 80% of $10 + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(true); + }); + + test('should reject when position count is one over limit', () => { + const config = { ...defaultConfig, maxPositions: 10 }; + const equity = 10000; + const currentExposure = 1000; + const positionCount = 10; + const estimatedMargin = 100; + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, config); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain('10/10'); + }); + + test('should allow when exposure is just under limit', () => { + const equity = 1000; + const currentExposure = 700; + const positionCount = 5; + const estimatedMargin = 99; // Total: $799, just under $800 limit + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(true); + }); + + test('should reject when exposure is just over limit', () => { + const equity = 1000; + const currentExposure = 700; + const positionCount = 5; + const estimatedMargin = 101; // Total: $801, just over $800 limit + + const result = canOpenPosition(equity, currentExposure, positionCount, estimatedMargin, defaultConfig); + + expect(result.allowed).toBe(false); + }); + }); +}); diff --git a/tests/unit/position-sync.test.ts b/tests/unit/position-sync.test.ts new file mode 100644 index 0000000..6ad48dc --- /dev/null +++ b/tests/unit/position-sync.test.ts @@ -0,0 +1,507 @@ +/** + * Position Sync and Import Unit Tests + * Tests for syncing positions with exchange and importing external positions + */ + +import { syncPositions } from '../../src/services/position/position-manager'; +import type { Position, ScalperConfig } from '../../src/types'; +import type { AsterClient } from '../../src/services/execution/exchange-client'; + +// ============================================================================= +// TEST DATA +// ============================================================================= + +const defaultConfig: ScalperConfig = { + leverage: 10, + positionSizePercent: 35, + positionSizeUSD: null, + minPositionSizeUSD: 10, + maxPositionSizeUSD: 150, + maxExposurePercent: 80, + maxPositions: 20, + riskPerTradePercent: 2, + maxDailyLossPercent: 10, + maxDrawdownPercent: 20, + dailyProfitTargetPercent: 0, + tickIntervalMs: 15000, + scanIntervalTicks: 2, + maxHoldTimeMinutes: 5, + takeProfitROE: 1.5, + stopLossROE: -0.4, + minProfitUSD: 0.2, + trailingActivationROE: 0.5, + trailingDistanceROE: 0.2, + minIvishXConfidence: 5, + minCombinedConfidence: 55, + requireLLMAgreement: false, + minConfidenceWithoutLLM: 50, + minScoreForSignal: 50, + rsiPeriod: 14, + rsiOversold: 35, + rsiOverbought: 65, + momentumPeriod: 3, + minMomentum: 0.2, + maxMomentum: 3.0, + volumePeriod: 20, + minVolumeRatio: 0.3, + trendSMAFast: 10, + trendSMASlow: 20, + klineInterval: '5m', + klineCount: 60, + llmEnabled: false, + llmConfidenceBoost: 15, + llmExitAnalysisEnabled: false, + llmExitAnalysisMinutes: 2, + llmExitConfidenceThreshold: 80, + bounceDetectionEnabled: true, + bounceRSIThreshold: 35, + bounceStochThreshold: 25, + bounceWilliamsThreshold: -75, + bounceMinGreenCandles: 2, + bounceBonusPoints: 20, +}; + +function createTestPosition(symbol: string, overrides: Partial = {}): Position { + return { + id: `test-pos-${symbol}`, + agentId: 'test-agent', + userId: 'test-user', + symbol, + side: 'long', + size: 0.001, + entryPrice: 50000, + currentPrice: 50000, + leverage: 10, + marginUsed: 5, + unrealizedPnl: 0, + unrealizedROE: 0, + highestROE: 0, + lowestROE: 0, + stopLoss: null, + takeProfit: null, + trailingActivated: false, + trailingStopPrice: null, + ivishxConfidence: 7, + llmConfidence: 65, + entryReason: ['Test entry'], + openedAt: Date.now(), + updatedAt: Date.now(), + maxHoldTime: 5 * 60 * 1000, + ...overrides, + }; +} + +function createMockClient(exchangePositions: any[]): AsterClient { + return { + getPositions: jest.fn().mockResolvedValue(exchangePositions), + } as any; +} + +// ============================================================================= +// POSITION SYNC TESTS +// ============================================================================= + +describe('syncPositions', () => { + describe('Basic Sync Functionality', () => { + test('should return empty arrays when no positions exist', async () => { + const client = createMockClient([]); + const localPositions = new Map(); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.synced).toEqual([]); + expect(result.closed).toEqual([]); + expect(result.opened).toEqual([]); + expect(result.imported).toEqual([]); + }); + + test('should sync existing local positions that are still on exchange', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT')); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.synced).toContain('BTCUSDT'); + expect(result.closed).toEqual([]); + expect(result.opened).toEqual([]); + expect(localPositions.has('BTCUSDT')).toBe(true); + }); + + test('should detect positions closed externally', async () => { + const client = createMockClient([]); // No positions on exchange + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT')); + localPositions.set('ETHUSDT', createTestPosition('ETHUSDT')); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.closed).toContain('BTCUSDT'); + expect(result.closed).toContain('ETHUSDT'); + expect(localPositions.size).toBe(0); + }); + + test('should remove closed positions from local map', async () => { + const client = createMockClient([]); + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT')); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(localPositions.has('BTCUSDT')).toBe(false); + }); + }); + + describe('Position Import', () => { + test('should import external long position', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '0.001', + entryPrice: '50000', + leverage: '10', + unrealizedProfit: '5', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.imported).toContain('BTCUSDT'); + expect(result.opened).toContain('BTCUSDT'); + expect(localPositions.has('BTCUSDT')).toBe(true); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.side).toBe('long'); + expect(imported.size).toBe(0.001); + expect(imported.entryPrice).toBe(50000); + expect(imported.isExternal).toBe(true); + }); + + test('should import external short position', async () => { + const exchangePositions = [ + { + symbol: 'ETHUSDT', + positionAmt: '-0.5', + entryPrice: '2000', + leverage: '10', + unrealizedProfit: '-10', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.imported).toContain('ETHUSDT'); + const imported = localPositions.get('ETHUSDT')!; + expect(imported.side).toBe('short'); + expect(imported.size).toBe(0.5); + }); + + test('should calculate margin correctly for imported positions', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '0.001', + entryPrice: '50000', + leverage: '10', + unrealizedProfit: '5', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + // Margin = (size * entryPrice) / leverage = (0.001 * 50000) / 10 = 5 + expect(imported.marginUsed).toBe(5); + }); + + test('should calculate ROE correctly for imported positions', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '0.001', + entryPrice: '50000', + leverage: '10', + unrealizedProfit: '1', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + // ROE = (unrealizedPnl / margin) * 100 = (1 / 5) * 100 = 20% + expect(imported.unrealizedROE).toBe(20); + expect(imported.highestROE).toBe(20); + expect(imported.lowestROE).toBe(20); + }); + + test('should handle missing leverage (default to 10)', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '0.001', + entryPrice: '50000', + unrealizedProfit: '5', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.leverage).toBe(10); + }); + + test('should import multiple external positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + { symbol: 'ETHUSDT', positionAmt: '0.5', entryPrice: '2000', leverage: '10', unrealizedProfit: '10' }, + { symbol: 'BNBUSDT', positionAmt: '-1.0', entryPrice: '300', leverage: '10', unrealizedProfit: '-5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.imported).toHaveLength(3); + expect(result.imported).toContain('BTCUSDT'); + expect(result.imported).toContain('ETHUSDT'); + expect(result.imported).toContain('BNBUSDT'); + expect(localPositions.size).toBe(3); + }); + + test('should set correct fields for imported positions', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '0.001', + entryPrice: '50000', + leverage: '15', + unrealizedProfit: '7.5', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.agentId).toBe('test-agent'); + expect(imported.isExternal).toBe(true); + expect(imported.symbol).toBe('BTCUSDT'); + expect(imported.originalSize).toBe(0.001); + expect(imported.currentPrice).toBe(50000); // Set to entry initially + expect(imported.openedAt).toBeDefined(); + expect(imported.updatedAt).toBeDefined(); + }); + }); + + describe('Mixed Sync Scenarios', () => { + test('should sync existing and import new positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + { symbol: 'ETHUSDT', positionAmt: '0.5', entryPrice: '2000', leverage: '10', unrealizedProfit: '10' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT')); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.synced).toContain('BTCUSDT'); + expect(result.imported).toContain('ETHUSDT'); + expect(localPositions.size).toBe(2); + }); + + test('should sync existing, close some, and import new positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + { symbol: 'BNBUSDT', positionAmt: '1.0', entryPrice: '300', leverage: '10', unrealizedProfit: '3' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT')); + localPositions.set('ETHUSDT', createTestPosition('ETHUSDT')); // Will be closed + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.synced).toContain('BTCUSDT'); + expect(result.closed).toContain('ETHUSDT'); + expect(result.imported).toContain('BNBUSDT'); + expect(localPositions.size).toBe(2); + expect(localPositions.has('BTCUSDT')).toBe(true); + expect(localPositions.has('ETHUSDT')).toBe(false); + expect(localPositions.has('BNBUSDT')).toBe(true); + }); + + test('should not re-import already local positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + const originalPosition = createTestPosition('BTCUSDT'); + localPositions.set('BTCUSDT', originalPosition); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + expect(result.synced).toContain('BTCUSDT'); + expect(result.imported).toEqual([]); + // Should keep original position object + expect(localPositions.get('BTCUSDT')).toBe(originalPosition); + }); + }); + + describe('Edge Cases', () => { + test('should handle zero position amounts gracefully', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0', entryPrice: '50000', leverage: '10', unrealizedProfit: '0' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + // Zero positions should still be imported (exchange might return them) + expect(result.imported).toContain('BTCUSDT'); + const imported = localPositions.get('BTCUSDT')!; + expect(imported.size).toBe(0); + }); + + test('should handle negative position amounts for shorts', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '-0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.side).toBe('short'); + expect(imported.size).toBe(0.001); // Absolute value + }); + + test('should handle API errors gracefully', async () => { + const client = { + getPositions: jest.fn().mockRejectedValue(new Error('API error')), + } as any; + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT')); + + const result = await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + // Should return empty results on error, not throw + expect(result.synced).toEqual([]); + expect(result.closed).toEqual([]); + expect(result.imported).toEqual([]); + // Local positions should remain unchanged + expect(localPositions.size).toBe(1); + }); + + test('should handle malformed exchange position data', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT' }, // Missing required fields + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + // Should not throw, might import with defaults + await expect(syncPositions(client, localPositions, 'test-agent', defaultConfig)).resolves.toBeDefined(); + }); + + test('should handle very large position sizes', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '100.0', + entryPrice: '50000', + leverage: '10', + unrealizedProfit: '50000', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.size).toBe(100); + expect(imported.marginUsed).toBe(500000); // 100 * 50000 / 10 + }); + + test('should handle fractional leverage values', async () => { + const exchangePositions = [ + { + symbol: 'BTCUSDT', + positionAmt: '0.001', + entryPrice: '50000', + leverage: '7.5', + unrealizedProfit: '5', + }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.leverage).toBe(7.5); + // Margin = 0.001 * 50000 / 7.5 = 6.67 + expect(imported.marginUsed).toBeCloseTo(6.67, 2); + }); + + test('should assign unique IDs to imported positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'test-agent', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.id).toMatch(/^imported-BTCUSDT-\d+$/); + }); + }); + + describe('Agent ID Handling', () => { + test('should assign correct agent ID to imported positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + + await syncPositions(client, localPositions, 'custom-agent-123', defaultConfig); + + const imported = localPositions.get('BTCUSDT')!; + expect(imported.agentId).toBe('custom-agent-123'); + }); + + test('should preserve agent ID in synced positions', async () => { + const exchangePositions = [ + { symbol: 'BTCUSDT', positionAmt: '0.001', entryPrice: '50000', leverage: '10', unrealizedProfit: '5' }, + ]; + const client = createMockClient(exchangePositions); + const localPositions = new Map(); + localPositions.set('BTCUSDT', createTestPosition('BTCUSDT', { agentId: 'original-agent' })); + + await syncPositions(client, localPositions, 'new-agent', defaultConfig); + + const synced = localPositions.get('BTCUSDT')!; + expect(synced.agentId).toBe('original-agent'); // Should not change + }); + }); +});