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