Files
trading_bot_v4/tests/integration/position-manager/safe-orphan-removal.test.ts
2025-12-09 22:27:58 +00:00

267 lines
7.9 KiB
TypeScript

/**
* Bug #78 Test: Safe Orphan Removal
*
* Tests that removeTrade() checks Drift position size before canceling orders
* to avoid removing active position protection.
*
* Created: Dec 9, 2025
* Reason: Bug #78 - cancelAllOrders affects ALL positions on symbol
*/
import { PositionManager } from '../../../lib/trading/position-manager'
import { createMockTrade } from '../../helpers/trade-factory'
// Mock dependencies
jest.mock('../../../lib/drift/client')
jest.mock('../../../lib/drift/orders')
jest.mock('../../../lib/pyth/price-monitor')
jest.mock('../../../lib/database/trades')
jest.mock('../../../lib/notifications/telegram')
jest.mock('../../../config/trading')
describe('Bug #78: Safe Orphan Removal', () => {
let manager: PositionManager
let mockDriftService: any
let mockCancelAllOrders: jest.Mock
beforeEach(() => {
jest.clearAllMocks()
// Mock Drift service
mockDriftService = {
getPosition: jest.fn()
}
const { getDriftService } = require('../../../lib/drift/client')
getDriftService.mockReturnValue(mockDriftService)
// Mock cancelAllOrders
const ordersModule = require('../../../lib/drift/orders')
mockCancelAllOrders = jest.fn().mockResolvedValue({ success: true, cancelledCount: 2 })
ordersModule.cancelAllOrders = mockCancelAllOrders
// Mock Pyth price monitor
const { getPythPriceMonitor } = require('../../../lib/pyth/price-monitor')
getPythPriceMonitor.mockReturnValue({
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined)
})
// Mock trading config
const { getMarketConfig } = require('../../../config/trading')
getMarketConfig.mockReturnValue({
driftMarketIndex: 0,
minOrderSize: 0.1
})
manager = new PositionManager()
})
describe('When Drift Position is Closed', () => {
it('should cancel orders when Drift confirms position closed (size = 0)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP',
direction: 'long'
})
await manager.addTrade(trade)
// Mock Drift position as closed
mockDriftService.getPosition.mockResolvedValue({
size: 0,
side: 'none'
})
await manager.removeTrade(trade.id)
// Should have called cancelAllOrders since position is closed
expect(mockCancelAllOrders).toHaveBeenCalledWith('SOL-PERP')
expect(mockCancelAllOrders).toHaveBeenCalledTimes(1)
})
it('should cancel orders when Drift position size is negligible (< 0.01)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Mock Drift position with negligible size
mockDriftService.getPosition.mockResolvedValue({
size: 0.005,
side: 'long'
})
await manager.removeTrade(trade.id)
// Should cancel since size is negligible
expect(mockCancelAllOrders).toHaveBeenCalledWith('SOL-PERP')
})
})
describe('When Drift Position is Still Open', () => {
it('should NOT cancel orders when Drift shows open position (size >= 0.01)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Mock Drift position as still open
mockDriftService.getPosition.mockResolvedValue({
size: 14.47, // Real position size
side: 'long',
entryPrice: 138.45
})
await manager.removeTrade(trade.id)
// Should NOT have called cancelAllOrders
expect(mockCancelAllOrders).not.toHaveBeenCalled()
})
it('should remove trade from tracking even when skipping order cancellation', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Verify trade is tracked
expect((manager as any).activeTrades.has(trade.id)).toBe(true)
// Mock Drift position as open
mockDriftService.getPosition.mockResolvedValue({
size: 10.5,
side: 'long'
})
await manager.removeTrade(trade.id)
// Trade should be removed from tracking
expect((manager as any).activeTrades.has(trade.id)).toBe(false)
// But orders should not be canceled
expect(mockCancelAllOrders).not.toHaveBeenCalled()
})
it('should log warning when skipping cancellation for safety', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
mockDriftService.getPosition.mockResolvedValue({
size: 12.28,
side: 'long'
})
await manager.removeTrade(trade.id)
// Should have logged safety warning
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('SAFETY CHECK')
)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Skipping order cancellation')
)
consoleSpy.mockRestore()
})
})
describe('Error Handling', () => {
it('should NOT cancel orders on Drift query error (err on side of caution)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Mock Drift error
mockDriftService.getPosition.mockRejectedValue(new Error('RPC error'))
await manager.removeTrade(trade.id)
// Should NOT cancel orders on error (safety first)
expect(mockCancelAllOrders).not.toHaveBeenCalled()
// But should remove from tracking
expect((manager as any).activeTrades.has(trade.id)).toBe(false)
})
it('should log error when Drift check fails', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
mockDriftService.getPosition.mockRejectedValue(new Error('Network timeout'))
await manager.removeTrade(trade.id)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error checking Drift position')
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Removing from tracking without canceling orders')
)
consoleSpy.mockRestore()
consoleWarnSpy.mockRestore()
})
})
describe('Multiple Positions on Same Symbol', () => {
it('should preserve orders when removing old position while new position exists', async () => {
const oldTrade = createMockTrade({
id: 'old-trade',
symbol: 'SOL-PERP',
direction: 'long'
})
const newTrade = createMockTrade({
id: 'new-trade',
symbol: 'SOL-PERP',
direction: 'long'
})
// Add both trades (simulating orphan detection scenario)
await manager.addTrade(oldTrade)
await manager.addTrade(newTrade)
// Mock Drift showing active position (belongs to new trade)
mockDriftService.getPosition.mockResolvedValue({
size: 14.47,
side: 'long',
entryPrice: 138.45
})
// Remove old trade (orphan cleanup)
await manager.removeTrade(oldTrade.id)
// Should NOT cancel orders (protects new trade)
expect(mockCancelAllOrders).not.toHaveBeenCalled()
// Old trade removed
expect((manager as any).activeTrades.has(oldTrade.id)).toBe(false)
// New trade still tracked
expect((manager as any).activeTrades.has(newTrade.id)).toBe(true)
})
})
})