- Enhanced DNS failover monitor on secondary (72.62.39.24) - Auto-promotes database: pg_ctl promote on failover - Creates DEMOTED flag on primary via SSH (split-brain protection) - Telegram notifications with database promotion status - Startup safety script ready (integration pending) - 90-second automatic recovery vs 10-30 min manual - Zero-cost 95% enterprise HA benefit Status: DEPLOYED and MONITORING (14:52 CET) Next: Controlled failover test during maintenance
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
/**
|
|
* CRITICAL TEST: Position Manager Actually Monitors
|
|
*
|
|
* This test validates that Position Manager doesn't just say "added" but ACTUALLY
|
|
* starts monitoring positions. This bug caused $1,000+ in losses.
|
|
*
|
|
* Bug: Position Manager logs "✅ Trade added" but never actually monitors
|
|
* Impact: No TP/SL monitoring, no protection, uncontrolled losses
|
|
*
|
|
* Created: Dec 8, 2025
|
|
* Reason: User lost $1,000 because Position Manager never monitored despite logs claiming it did
|
|
*/
|
|
|
|
import { PositionManager } from '../../../lib/trading/position-manager'
|
|
import { ActiveTrade } from '../../../lib/trading/position-manager'
|
|
import { createMockTrade } from '../../helpers/trade-factory'
|
|
|
|
// Mock dependencies
|
|
const mockDriftService = {
|
|
isInitialized: true,
|
|
getPosition: jest.fn().mockResolvedValue({ size: 0 }),
|
|
getClient: jest.fn(() => ({})),
|
|
}
|
|
|
|
jest.mock('../../../lib/drift/client', () => ({
|
|
getDriftService: jest.fn(() => mockDriftService),
|
|
}))
|
|
|
|
jest.mock('../../../lib/drift/orders', () => ({
|
|
placeExitOrders: jest.fn(async () => ({
|
|
success: true,
|
|
signatures: [],
|
|
expectedOrders: 0,
|
|
placedOrders: 0,
|
|
})),
|
|
closePosition: jest.fn(async () => ({ success: true })),
|
|
cancelAllOrders: jest.fn(async () => ({ success: true })),
|
|
}))
|
|
|
|
jest.mock('../../../lib/pyth/price-monitor')
|
|
jest.mock('../../../lib/database/trades')
|
|
jest.mock('../../../lib/notifications/telegram')
|
|
jest.mock('../../../lib/utils/persistent-logger', () => ({
|
|
logCriticalError: jest.fn(),
|
|
logError: jest.fn(),
|
|
logWarning: jest.fn(),
|
|
}))
|
|
|
|
describe('Position Manager Monitoring Verification', () => {
|
|
let manager: PositionManager
|
|
let mockPriceMonitor: any
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
mockDriftService.getPosition.mockResolvedValue({ size: 1 })
|
|
|
|
// Mock Pyth price monitor
|
|
mockPriceMonitor = {
|
|
start: jest.fn().mockResolvedValue(undefined),
|
|
stop: jest.fn().mockResolvedValue(undefined),
|
|
getLatestPrice: jest.fn().mockResolvedValue(140.00)
|
|
}
|
|
|
|
const { getPythPriceMonitor } = require('../../../lib/pyth/price-monitor')
|
|
getPythPriceMonitor.mockReturnValue(mockPriceMonitor)
|
|
|
|
manager = new PositionManager()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if ((manager as any)?.isMonitoring) {
|
|
await (manager as any).stopMonitoring?.()
|
|
}
|
|
})
|
|
|
|
describe('CRITICAL: Monitoring Actually Starts', () => {
|
|
it('should start Pyth price monitor when trade added', async () => {
|
|
const trade = createMockTrade({ direction: 'long', symbol: 'SOL-PERP' })
|
|
|
|
await manager.addTrade(trade)
|
|
|
|
// CRITICAL: Verify Pyth monitor.start() was actually called
|
|
expect(mockPriceMonitor.start).toHaveBeenCalledTimes(1)
|
|
expect(mockPriceMonitor.start).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
symbols: ['SOL-PERP'],
|
|
onPriceUpdate: expect.any(Function),
|
|
onError: expect.any(Function)
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should set isMonitoring flag to true after starting', async () => {
|
|
const trade = createMockTrade({ direction: 'long' })
|
|
|
|
await manager.addTrade(trade)
|
|
|
|
// Access private property via type assertion for testing
|
|
const monitoring = (manager as any).isMonitoring
|
|
expect(monitoring).toBe(true)
|
|
})
|
|
|
|
it('should NOT start monitoring twice if already active', async () => {
|
|
const trade1 = createMockTrade({ direction: 'long', symbol: 'SOL-PERP' })
|
|
const trade2 = createMockTrade({ direction: 'long', symbol: 'SOL-PERP', id: 'trade2' })
|
|
|
|
await manager.addTrade(trade1)
|
|
await manager.addTrade(trade2)
|
|
|
|
// Should only call start() once (not twice)
|
|
expect(mockPriceMonitor.start).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should track multiple symbols in single monitoring session', async () => {
|
|
const solTrade = createMockTrade({ direction: 'long', symbol: 'SOL-PERP' })
|
|
const ethTrade = createMockTrade({ direction: 'long', symbol: 'ETH-PERP', id: 'eth-trade' })
|
|
|
|
await manager.addTrade(solTrade)
|
|
|
|
// Start should be called first time
|
|
expect(mockPriceMonitor.start).toHaveBeenCalledTimes(1)
|
|
expect(mockPriceMonitor.start).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
symbols: ['SOL-PERP']
|
|
})
|
|
)
|
|
|
|
// Adding second symbol should NOT restart monitor (optimization)
|
|
// Existing monitor handles all symbols dynamically
|
|
mockPriceMonitor.start.mockClear()
|
|
await manager.addTrade(ethTrade)
|
|
|
|
// Should NOT call start again - monitor already running
|
|
expect(mockPriceMonitor.start).toHaveBeenCalledTimes(0)
|
|
|
|
// Both trades should be tracked
|
|
const activeTrades = (manager as any).activeTrades
|
|
expect(activeTrades.size).toBe(2)
|
|
expect(activeTrades.has(solTrade.id)).toBe(true)
|
|
expect(activeTrades.has(ethTrade.id)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('CRITICAL: Price Updates Actually Trigger Checks', () => {
|
|
it('should call price update handler when Pyth sends updates', async () => {
|
|
const trade = createMockTrade({
|
|
direction: 'long',
|
|
symbol: 'SOL-PERP',
|
|
entryPrice: 140.00,
|
|
tp1Price: 141.20
|
|
})
|
|
|
|
await manager.addTrade(trade)
|
|
|
|
// Get the onPriceUpdate callback that was registered
|
|
const startCall = mockPriceMonitor.start.mock.calls[0][0]
|
|
const onPriceUpdate = startCall.onPriceUpdate
|
|
|
|
expect(onPriceUpdate).toBeDefined()
|
|
|
|
// Simulate price update
|
|
await onPriceUpdate({ symbol: 'SOL-PERP', price: 141.25, timestamp: Date.now() })
|
|
|
|
// Trade should have updated lastPrice
|
|
const activeTrade = (manager as any).activeTrades.get(trade.id)
|
|
expect(activeTrade.lastPrice).toBe(141.25)
|
|
expect(activeTrade.priceCheckCount).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should update lastUpdateTime on every price check', async () => {
|
|
const trade = createMockTrade({ direction: 'long' })
|
|
|
|
await manager.addTrade(trade)
|
|
|
|
const startCall = mockPriceMonitor.start.mock.calls[0][0]
|
|
const onPriceUpdate = startCall.onPriceUpdate
|
|
|
|
const before = Date.now()
|
|
await onPriceUpdate({ symbol: 'SOL-PERP', price: 140.50, timestamp: Date.now() })
|
|
const after = Date.now()
|
|
|
|
const activeTrade = (manager as any).activeTrades.get(trade.id)
|
|
expect(activeTrade.lastUpdateTime).toBeGreaterThanOrEqual(before)
|
|
expect(activeTrade.lastUpdateTime).toBeLessThanOrEqual(after)
|
|
})
|
|
})
|
|
|
|
describe('CRITICAL: Monitoring Stops When No Trades', () => {
|
|
it('should stop monitoring when last trade removed', async () => {
|
|
const trade = createMockTrade({ direction: 'long' })
|
|
|
|
await manager.addTrade(trade)
|
|
expect(mockPriceMonitor.start).toHaveBeenCalled()
|
|
|
|
await manager.removeTrade(trade.id)
|
|
|
|
expect(mockPriceMonitor.stop).toHaveBeenCalledTimes(1)
|
|
|
|
const monitoring = (manager as any).isMonitoring
|
|
expect(monitoring).toBe(false)
|
|
})
|
|
|
|
it('should NOT stop monitoring if other trades still active', async () => {
|
|
const trade1 = createMockTrade({ direction: 'long', id: 'trade1' })
|
|
const trade2 = createMockTrade({ direction: 'long', id: 'trade2' })
|
|
|
|
await manager.addTrade(trade1)
|
|
await manager.addTrade(trade2)
|
|
|
|
await manager.removeTrade(trade1.id)
|
|
|
|
// Should NOT have stopped (trade2 still active)
|
|
expect(mockPriceMonitor.stop).not.toHaveBeenCalled()
|
|
|
|
const monitoring = (manager as any).isMonitoring
|
|
expect(monitoring).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('CRITICAL: Error Handling Doesnt Break Monitoring', () => {
|
|
it('should continue monitoring other trades if one trade errors', async () => {
|
|
const trade1 = createMockTrade({ direction: 'long', id: 'trade1', symbol: 'SOL-PERP' })
|
|
const trade2 = createMockTrade({ direction: 'long', id: 'trade2', symbol: 'SOL-PERP' })
|
|
|
|
await manager.addTrade(trade1)
|
|
await manager.addTrade(trade2)
|
|
|
|
const startCall = mockPriceMonitor.start.mock.calls[0][0]
|
|
const onPriceUpdate = startCall.onPriceUpdate
|
|
|
|
// Should not throw - errors should be caught internally
|
|
await expect(onPriceUpdate({
|
|
symbol: 'SOL-PERP',
|
|
price: 141.00,
|
|
timestamp: Date.now()
|
|
})).resolves.not.toThrow()
|
|
|
|
// Both trades should still be tracked
|
|
const activeTrades = (manager as any).activeTrades
|
|
expect(activeTrades.size).toBe(2)
|
|
expect(activeTrades.has('trade1')).toBe(true)
|
|
expect(activeTrades.has('trade2')).toBe(true)
|
|
})
|
|
})
|
|
})
|