Files
trading_bot_v4/tests/integration/drift-state-verifier/position-verification.test.ts
2025-12-10 15:05:44 +01:00

535 lines
16 KiB
TypeScript

// @ts-nocheck
/**
* Drift State Verifier - Position Verification Tests
*
* Tests for Bug #82 long-term fix: Comprehensive position identity verification
* before attempting automatic close of orphaned positions.
*
* Created: Dec 10, 2025
*/
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
import { getDriftStateVerifier } from '../../../lib/monitoring/drift-state-verifier'
import { closePosition as importedClosePosition } from '../../../lib/drift/orders'
// Mock dependencies
const mockDriftService = {
getPosition: jest.fn(),
}
const mockPrisma = {
trade: {
findUnique: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
}
const asMock = (fn: any) => fn as jest.Mock
jest.mock('../../../lib/drift/client', () => ({
getDriftService: jest.fn(() => Promise.resolve(mockDriftService)),
}))
jest.mock('../../../lib/database/trades', () => ({
getPrismaClient: jest.fn(() => mockPrisma),
}))
jest.mock('../../../lib/drift/orders', () => ({
closePosition: jest.fn(),
}))
jest.mock('../../../lib/notifications/telegram', () => ({
sendTelegramMessage: jest.fn(),
}))
// Import DriftStateVerifier after mocks are set up
// NOTE: Actual import will need to be added based on your export structure
describe('Drift State Verifier - Position Verification', () => {
let verifier: any
const mockClosePosition = importedClosePosition as jest.Mock
beforeEach(() => {
// Reset all mocks before each test
jest.clearAllMocks()
verifier = getDriftStateVerifier()
verifier.recentCloseAttempts = new Map()
})
describe('CRITICAL: Active Position Protection', () => {
it('should NOT close position when newer trade exists in database', async () => {
// Scenario: User opened new position AFTER the DB record we're checking
const oldTradeExitTime = new Date('2025-12-10T10:00:00Z')
const newTradeCreatedTime = new Date('2025-12-10T10:15:00Z')
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'old-trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date('2025-12-10T09:00:00Z'),
exitTime: oldTradeExitTime,
exitReason: 'SL',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 15.45, // SOL tokens
entryPrice: 142.5, // Different price = new position!
unrealizedPnL: 45.2,
side: 'long',
})
// KEY: Database shows newer open trade
asMock(mockPrisma.trade.findMany).mockResolvedValue([
{
id: 'new-trade-456',
createdAt: newTradeCreatedTime,
},
])
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null) // No recent cooldown
// Simulate verification call
await verifier.processMismatch({
tradeId: 'old-trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 15.45,
dbExitReason: 'SL',
timeSinceExit: 30 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should NOT close when entry price differs by >2%', async () => {
const exitTime = new Date(Date.now() - 20 * 60 * 1000) // 20 min ago (past grace period)
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime,
exitReason: 'TP1',
positionSizeUSD: 8000,
entryPrice: 140.0, // DB says entry at $140
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 57.14, // Size matches perfectly
entryPrice: 143.5, // But entry price 2.5% higher = different position!
unrealizedPnL: 120.5,
side: 'long',
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([]) // No newer trades
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
// Should skip due to entry price mismatch
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 57.14,
dbExitReason: 'TP1',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should NOT close when size differs by >15%', async () => {
const exitTime = new Date(Date.now() - 20 * 60 * 1000)
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime,
exitReason: 'TP1',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 45.0, // 45 tokens vs expected 57.14 = 79% ratio (below 85% threshold)
entryPrice: 140.0,
unrealizedPnL: -25.5,
side: 'long',
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([])
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 45.0,
dbExitReason: 'TP1',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should NOT close when direction differs', async () => {
const exitTime = new Date(Date.now() - 20 * 60 * 1000)
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long', // DB says LONG
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime,
exitReason: 'SL',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: -57.14, // Negative = SHORT position
entryPrice: 140.0,
unrealizedPnL: 80.0,
side: 'short', // Drift shows SHORT
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([])
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: -57.14,
dbExitReason: 'SL',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should NOT close within 10-minute grace period', async () => {
const exitTime = new Date(Date.now() - 5 * 60 * 1000) // Only 5 minutes ago
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 15 * 60 * 1000),
exitTime,
exitReason: 'TP2',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 57.14,
entryPrice: 140.0,
unrealizedPnL: 45.2,
side: 'long',
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([])
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
// Should skip due to grace period
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 57.14,
dbExitReason: 'TP2',
timeSinceExit: 5 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
})
describe('CRITICAL: Verified Ghost Closure', () => {
it('should close when all verification checks pass', async () => {
const exitTime = new Date(Date.now() - 20 * 60 * 1000) // 20 min ago (past grace period)
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'ghost-trade-789',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 40 * 60 * 1000),
exitTime,
exitReason: 'SL',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 57.14, // Size matches within 15% tolerance
entryPrice: 140.2, // Price matches within 2%
unrealizedPnL: -120.5,
side: 'long', // Direction matches
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([]) // No newer trades
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null) // No cooldown
asMock(mockClosePosition).mockResolvedValue({
success: true,
transactionSignature: '5YxABC123...',
realizedPnL: -120.5,
})
asMock(mockPrisma.trade.update).mockResolvedValue({})
// Simulate verification and close
await verifier.processMismatch({
tradeId: 'ghost-trade-789',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 57.14,
dbExitReason: 'SL',
timeSinceExit: 20 * 60 * 1000,
})
// Should have called closePosition with correct parameters
expect(mockClosePosition).toHaveBeenCalledWith({
symbol: 'SOL-PERP',
percentToClose: 100,
slippageTolerance: 0.05,
})
// Should have updated database with cleanup metadata
expect(mockPrisma.trade.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'ghost-trade-789' },
})
)
})
it('should enforce 5-minute cooldown between attempts', async () => {
const exitTime = new Date(Date.now() - 20 * 60 * 1000)
const lastAttempt = Date.now() - 2 * 60 * 1000 // Only 2 minutes ago
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime,
exitReason: 'SL',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 57.14,
entryPrice: 140.0,
unrealizedPnL: -45.2,
side: 'long',
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([])
// Database shows recent cleanup attempt
asMock(mockPrisma.trade.findFirst).mockResolvedValue({
configSnapshot: {
orphanCleanupTime: new Date(lastAttempt).toISOString(),
},
})
// Should skip due to cooldown
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 57.14,
dbExitReason: 'SL',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
})
describe('CRITICAL: Edge Case Handling', () => {
it('should handle missing database trade gracefully', async () => {
asMock(mockPrisma.trade.findUnique).mockResolvedValue(null)
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
// Should not attempt close if DB record missing
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 0,
dbExitReason: null,
timeSinceExit: 0,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should handle Drift position already closed', async () => {
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime: new Date(Date.now() - 20 * 60 * 1000),
exitReason: 'TP1',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
// Position already closed on Drift
asMock(mockDriftService.getPosition).mockResolvedValue(null)
// Should not attempt close - already resolved
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 0,
dbExitReason: 'TP1',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should handle unknown market index gracefully', async () => {
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'UNKNOWN-PERP', // Invalid symbol
direction: 'long',
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime: new Date(Date.now() - 20 * 60 * 1000),
exitReason: 'SL',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
// Should skip unknown markets
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'UNKNOWN-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 0,
dbExitReason: 'SL',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should log protection events to database', async () => {
const exitTime = new Date(Date.now() - 20 * 60 * 1000)
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 30 * 60 * 1000),
exitTime,
exitReason: 'TP1',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 57.14,
entryPrice: 145.0, // 3.6% price difference = protection trigger
unrealizedPnL: 180.0,
side: 'long',
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([])
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
asMock(mockPrisma.trade.update).mockResolvedValue({})
// Should have logged protection event
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 57.14,
dbExitReason: 'TP1',
timeSinceExit: 20 * 60 * 1000,
})
expect(mockPrisma.trade.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'trade-123' },
data: expect.objectContaining({
configSnapshot: expect.objectContaining({
path: ['protectionEvents'],
}),
}),
})
)
})
})
describe('CRITICAL: Fail-Open Bias', () => {
it('should NOT close when verification is uncertain', async () => {
// Multiple ambiguous signals
const exitTime = new Date(Date.now() - 15 * 60 * 1000) // Near grace period boundary
asMock(mockPrisma.trade.findUnique).mockResolvedValue({
id: 'trade-123',
symbol: 'SOL-PERP',
direction: 'long',
entryTime: new Date(Date.now() - 25 * 60 * 1000),
exitTime,
exitReason: 'SL',
positionSizeUSD: 8000,
entryPrice: 140.0,
configSnapshot: {},
})
asMock(mockDriftService.getPosition).mockResolvedValue({
size: 55.0, // Size 96% of expected (within tolerance but marginal)
entryPrice: 142.5, // Price 1.8% different (within tolerance but marginal)
unrealizedPnL: 80.0,
side: 'long',
})
asMock(mockPrisma.trade.findMany).mockResolvedValue([]) // No newer trades (but uncertain)
asMock(mockPrisma.trade.findFirst).mockResolvedValue(null)
// When signals are ambiguous, should err on side of NOT closing
// (Better to miss cleanup than close active trade)
await verifier.processMismatch({
tradeId: 'trade-123',
symbol: 'SOL-PERP',
expectedState: 'closed',
actualState: 'open',
driftSize: 55.0,
dbExitReason: 'SL',
timeSinceExit: 15 * 60 * 1000,
})
expect(mockClosePosition).not.toHaveBeenCalled()
})
})
})