Co-authored-by: mindesbunister <32161838+mindesbunister@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Bug #80 Test: Retry Loop Cooldown Enforcement
|
||||
*
|
||||
* Tests that drift-state-verifier respects 5-minute cooldown and doesn't
|
||||
* repeatedly retry closing positions, which strips protection.
|
||||
*
|
||||
* Created: Dec 9, 2025
|
||||
* Reason: Bug #80 - Retry loop doesn't enforce cooldown properly
|
||||
*/
|
||||
|
||||
import { getDriftStateVerifier } from '../../../lib/monitoring/drift-state-verifier'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../lib/drift/client')
|
||||
jest.mock('../../../lib/drift/orders')
|
||||
jest.mock('../../../lib/database/trades')
|
||||
jest.mock('../../../lib/notifications/telegram')
|
||||
|
||||
describe('Bug #80: Retry Loop Cooldown', () => {
|
||||
let verifier: any
|
||||
let mockClosePosition: jest.Mock
|
||||
let mockPrisma: any
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Get verifier instance
|
||||
verifier = getDriftStateVerifier()
|
||||
|
||||
// Access private methods via type assertion for testing
|
||||
verifier.recentCloseAttempts = new Map()
|
||||
|
||||
// Mock Drift service
|
||||
const { getDriftService } = require('../../../lib/drift/client')
|
||||
getDriftService.mockResolvedValue({
|
||||
getPosition: jest.fn().mockResolvedValue({ size: 0, side: 'none' })
|
||||
})
|
||||
|
||||
// Mock closePosition
|
||||
const ordersModule = require('../../../lib/drift/orders')
|
||||
mockClosePosition = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
transactionSignature: 'CLOSE_TX',
|
||||
realizedPnL: -10.50
|
||||
})
|
||||
ordersModule.closePosition = mockClosePosition
|
||||
|
||||
// Mock Prisma
|
||||
const { getPrismaClient } = require('../../../lib/database/trades')
|
||||
mockPrisma = {
|
||||
trade: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn()
|
||||
}
|
||||
}
|
||||
getPrismaClient.mockReturnValue(mockPrisma)
|
||||
})
|
||||
|
||||
describe('In-Memory Cooldown Map', () => {
|
||||
it('should allow first close attempt', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
mockPrisma.trade.findUnique.mockResolvedValue({
|
||||
id: 'trade1',
|
||||
configSnapshot: {}
|
||||
})
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should have called closePosition
|
||||
expect(mockClosePosition).toHaveBeenCalledWith({
|
||||
symbol: 'SOL-PERP',
|
||||
percentToClose: 100,
|
||||
slippageTolerance: 0.05
|
||||
})
|
||||
|
||||
// Should have recorded attempt in map
|
||||
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
|
||||
})
|
||||
|
||||
it('should block retry within 5-minute cooldown', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
|
||||
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
// Set recent attempt (2 minutes ago)
|
||||
const twoMinutesAgo = Date.now() - (2 * 60 * 1000)
|
||||
verifier.recentCloseAttempts.set('SOL-PERP', twoMinutesAgo)
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should NOT have called closePosition
|
||||
expect(mockClosePosition).not.toHaveBeenCalled()
|
||||
|
||||
// Should have logged cooldown message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('COOLDOWN ACTIVE')
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should allow retry after cooldown expires (> 5 minutes)', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
// Set old attempt (6 minutes ago - cooldown expired)
|
||||
const sixMinutesAgo = Date.now() - (6 * 60 * 1000)
|
||||
verifier.recentCloseAttempts.set('SOL-PERP', sixMinutesAgo)
|
||||
|
||||
mockPrisma.trade.findUnique.mockResolvedValue({
|
||||
id: 'trade1',
|
||||
configSnapshot: {}
|
||||
})
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should have called closePosition (cooldown expired)
|
||||
expect(mockClosePosition).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log remaining cooldown time', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
|
||||
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
// Set recent attempt (90 seconds ago)
|
||||
const ninetySecondsAgo = Date.now() - (90 * 1000)
|
||||
verifier.recentCloseAttempts.set('SOL-PERP', ninetySecondsAgo)
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should log remaining wait time
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Must wait \d+s more/)
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should log cooldown map state', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
|
||||
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
// Set recent attempts for multiple symbols
|
||||
verifier.recentCloseAttempts.set('SOL-PERP', Date.now() - 30000)
|
||||
verifier.recentCloseAttempts.set('ETH-PERP', Date.now() - 60000)
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should log map state
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Cooldown map state:')
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Database Cooldown Persistence', () => {
|
||||
it('should check database for cooldown even if map is empty', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
// Map is empty (simulates restart)
|
||||
expect(verifier.recentCloseAttempts.size).toBe(0)
|
||||
|
||||
// But database has recent attempt
|
||||
const twoMinutesAgo = new Date(Date.now() - (2 * 60 * 1000))
|
||||
mockPrisma.trade.findUnique.mockResolvedValue({
|
||||
id: 'trade1',
|
||||
configSnapshot: {
|
||||
retryCloseTime: twoMinutesAgo.toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should NOT have called closePosition (DB cooldown active)
|
||||
expect(mockClosePosition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update in-memory map from database', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
const oneMinuteAgo = new Date(Date.now() - (1 * 60 * 1000))
|
||||
mockPrisma.trade.findUnique.mockResolvedValue({
|
||||
id: 'trade1',
|
||||
configSnapshot: {
|
||||
retryCloseTime: oneMinuteAgo.toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should have updated map from DB
|
||||
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
|
||||
})
|
||||
|
||||
it('should persist attempt to database after successful close', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
mockPrisma.trade.findUnique.mockResolvedValue({
|
||||
id: 'trade1',
|
||||
configSnapshot: {}
|
||||
})
|
||||
|
||||
mockPrisma.trade.update.mockResolvedValue({})
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should have updated database with retry time
|
||||
expect(mockPrisma.trade.update).toHaveBeenCalledWith({
|
||||
where: { id: 'trade1' },
|
||||
data: expect.objectContaining({
|
||||
configSnapshot: expect.objectContaining({
|
||||
retryCloseAttempted: true,
|
||||
retryCloseTime: expect.any(String)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should record attempt even on close failure to prevent spam', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
mockPrisma.trade.findUnique.mockResolvedValue({
|
||||
id: 'trade1',
|
||||
configSnapshot: {}
|
||||
})
|
||||
|
||||
// Close fails
|
||||
mockClosePosition.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'RPC timeout'
|
||||
})
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should still have recorded attempt
|
||||
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
|
||||
})
|
||||
|
||||
it('should record attempt on exception to prevent rapid retries', async () => {
|
||||
const mismatch = {
|
||||
tradeId: 'trade1',
|
||||
symbol: 'SOL-PERP',
|
||||
expectedState: 'closed' as const,
|
||||
actualState: 'open' as const,
|
||||
driftSize: 14.47,
|
||||
dbExitReason: 'TP1',
|
||||
timeSinceExit: 60000
|
||||
}
|
||||
|
||||
mockPrisma.trade.findUnique.mockRejectedValue(new Error('Database error'))
|
||||
|
||||
await (verifier as any).retryClose(mismatch)
|
||||
|
||||
// Should have recorded attempt even on error
|
||||
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
183
tests/integration/orders/exit-orders-validation.test.ts
Normal file
183
tests/integration/orders/exit-orders-validation.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Bug #76 Test: Stop-Loss Placement Validation
|
||||
*
|
||||
* Tests that placeExitOrders returns failure when SL signature is missing
|
||||
* and logs critical errors appropriately.
|
||||
*
|
||||
* Created: Dec 9, 2025
|
||||
* Reason: Bug #76 - placeExitOrders() can return SUCCESS with missing SL orders
|
||||
*/
|
||||
|
||||
import { placeExitOrders, PlaceExitOrdersOptions } from '../../../lib/drift/orders'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../lib/drift/client')
|
||||
jest.mock('../../../lib/utils/logger')
|
||||
jest.mock('../../../config/trading')
|
||||
|
||||
describe('Bug #76: Exit Orders Validation', () => {
|
||||
let mockDriftClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Mock Drift service and client
|
||||
mockDriftClient = {
|
||||
placePerpOrder: jest.fn()
|
||||
}
|
||||
|
||||
const { getDriftService } = require('../../../lib/drift/client')
|
||||
getDriftService.mockReturnValue({
|
||||
getClient: () => mockDriftClient
|
||||
})
|
||||
|
||||
// Mock trading config
|
||||
const { getMarketConfig } = require('../../../config/trading')
|
||||
getMarketConfig.mockReturnValue({
|
||||
driftMarketIndex: 0,
|
||||
minOrderSize: 0.1
|
||||
})
|
||||
})
|
||||
|
||||
describe('Single Stop System', () => {
|
||||
it('should return success when all 3 orders placed (TP1 + TP2 + SL)', async () => {
|
||||
// Mock successful order placements
|
||||
mockDriftClient.placePerpOrder
|
||||
.mockResolvedValueOnce('TP1_SIG') // TP1
|
||||
.mockResolvedValueOnce('TP2_SIG') // TP2
|
||||
.mockResolvedValueOnce('SL_SIG') // SL
|
||||
|
||||
const options: PlaceExitOrdersOptions = {
|
||||
symbol: 'SOL-PERP',
|
||||
positionSizeUSD: 8000,
|
||||
entryPrice: 140.00,
|
||||
tp1Price: 141.20,
|
||||
tp2Price: 142.41,
|
||||
stopLossPrice: 138.71,
|
||||
tp1SizePercent: 75,
|
||||
tp2SizePercent: 0,
|
||||
direction: 'long',
|
||||
useDualStops: false
|
||||
}
|
||||
|
||||
const result = await placeExitOrders(options)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.signatures).toHaveLength(3)
|
||||
expect(result.signatures).toEqual(['TP1_SIG', 'TP2_SIG', 'SL_SIG'])
|
||||
})
|
||||
|
||||
it('should return failure when SL placement fails', async () => {
|
||||
// Mock TP1 and TP2 success, but SL fails
|
||||
mockDriftClient.placePerpOrder
|
||||
.mockResolvedValueOnce('TP1_SIG') // TP1
|
||||
.mockResolvedValueOnce('TP2_SIG') // TP2
|
||||
.mockRejectedValueOnce(new Error('Rate limit exceeded')) // SL fails
|
||||
|
||||
const options: PlaceExitOrdersOptions = {
|
||||
symbol: 'SOL-PERP',
|
||||
positionSizeUSD: 8000,
|
||||
entryPrice: 140.00,
|
||||
tp1Price: 141.20,
|
||||
tp2Price: 142.41,
|
||||
stopLossPrice: 138.71,
|
||||
tp1SizePercent: 75,
|
||||
tp2SizePercent: 0,
|
||||
direction: 'long',
|
||||
useDualStops: false
|
||||
}
|
||||
|
||||
const result = await placeExitOrders(options)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Stop loss placement failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dual Stop System', () => {
|
||||
it('should return success when all 4 orders placed (TP1 + TP2 + Soft + Hard)', async () => {
|
||||
// Mock successful order placements
|
||||
mockDriftClient.placePerpOrder
|
||||
.mockResolvedValueOnce('TP1_SIG') // TP1
|
||||
.mockResolvedValueOnce('TP2_SIG') // TP2
|
||||
.mockResolvedValueOnce('SOFT_SIG') // Soft Stop
|
||||
.mockResolvedValueOnce('HARD_SIG') // Hard Stop
|
||||
|
||||
const options: PlaceExitOrdersOptions = {
|
||||
symbol: 'SOL-PERP',
|
||||
positionSizeUSD: 8000,
|
||||
entryPrice: 140.00,
|
||||
tp1Price: 141.20,
|
||||
tp2Price: 142.41,
|
||||
stopLossPrice: 138.71,
|
||||
tp1SizePercent: 75,
|
||||
tp2SizePercent: 0,
|
||||
direction: 'long',
|
||||
useDualStops: true,
|
||||
softStopPrice: 139.00,
|
||||
hardStopPrice: 138.50
|
||||
}
|
||||
|
||||
const result = await placeExitOrders(options)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.signatures).toHaveLength(4)
|
||||
expect(result.signatures).toEqual(['TP1_SIG', 'TP2_SIG', 'SOFT_SIG', 'HARD_SIG'])
|
||||
})
|
||||
|
||||
it('should return failure when soft stop fails', async () => {
|
||||
mockDriftClient.placePerpOrder
|
||||
.mockResolvedValueOnce('TP1_SIG') // TP1
|
||||
.mockResolvedValueOnce('TP2_SIG') // TP2
|
||||
.mockRejectedValueOnce(new Error('Soft stop failed')) // Soft Stop
|
||||
|
||||
const options: PlaceExitOrdersOptions = {
|
||||
symbol: 'SOL-PERP',
|
||||
positionSizeUSD: 8000,
|
||||
entryPrice: 140.00,
|
||||
tp1Price: 141.20,
|
||||
tp2Price: 142.41,
|
||||
stopLossPrice: 138.71,
|
||||
tp1SizePercent: 75,
|
||||
tp2SizePercent: 0,
|
||||
direction: 'long',
|
||||
useDualStops: true,
|
||||
softStopPrice: 139.00,
|
||||
hardStopPrice: 138.50
|
||||
}
|
||||
|
||||
const result = await placeExitOrders(options)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Soft stop placement failed')
|
||||
})
|
||||
|
||||
it('should return failure when hard stop fails', async () => {
|
||||
mockDriftClient.placePerpOrder
|
||||
.mockResolvedValueOnce('TP1_SIG') // TP1
|
||||
.mockResolvedValueOnce('TP2_SIG') // TP2
|
||||
.mockResolvedValueOnce('SOFT_SIG') // Soft Stop
|
||||
.mockRejectedValueOnce(new Error('Hard stop failed')) // Hard Stop
|
||||
|
||||
const options: PlaceExitOrdersOptions = {
|
||||
symbol: 'SOL-PERP',
|
||||
positionSizeUSD: 8000,
|
||||
entryPrice: 140.00,
|
||||
tp1Price: 141.20,
|
||||
tp2Price: 142.41,
|
||||
stopLossPrice: 138.71,
|
||||
tp1SizePercent: 75,
|
||||
tp2SizePercent: 0,
|
||||
direction: 'long',
|
||||
useDualStops: true,
|
||||
softStopPrice: 139.00,
|
||||
hardStopPrice: 138.50
|
||||
}
|
||||
|
||||
const result = await placeExitOrders(options)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Hard stop placement failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
266
tests/integration/position-manager/safe-orphan-removal.test.ts
Normal file
266
tests/integration/position-manager/safe-orphan-removal.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user