Files
trading_bot_v4/lib/health/position-manager-health.ts
mindesbunister ba1fe4433e feat: Indicator score bypass - v11.2 sends SCORE:100 to bypass bot quality scoring
Changes:
- moneyline_v11_2_indicator.pinescript: Alert format now includes SCORE:100
- parse_signal_enhanced.json: Added indicatorScore parsing (SCORE:X regex)
- execute/route.ts: Added hasIndicatorScore bypass (score >= 90 bypasses quality check)
- Money_Machine.json: Both Execute Trade nodes now pass indicatorScore to API

Rationale: v11.2 indicator filters already optimized (2.544 PF, +51.80% return).
Bot-side quality scoring was blocking proven profitable signals (e.g., quality 75).
Now indicator passes SCORE:100, bot respects it and executes immediately.

This completes the signal chain:
Indicator (SCORE:100) → n8n parser (indicatorScore) → workflow → bot endpoint (bypass)
2025-12-26 11:40:12 +01:00

413 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Position Manager Health Check
*
* CRITICAL: Verifies Position Manager is actually monitoring positions
*
* Bug History:
* - $1,000+ losses because Position Manager logged "added" but never monitored
* - Silent SL placement failures left positions unprotected
* - Orphan detection cancelled orders on active positions
*
* This health check runs every 30 seconds to detect these critical failures.
*
* Created: Dec 8, 2025
*/
import { getInitializedPositionManager } from '../trading/position-manager'
import { getOpenTrades, getPrismaClient } from '../database/trades'
import { getDriftService } from '../drift/client'
import { getMergedConfig } from '../../config/trading'
export interface HealthCheckResult {
isHealthy: boolean
issues: string[]
warnings: string[]
info: {
dbOpenTrades: number
pmActiveTrades: number
pmMonitoring: boolean
driftPositions: number
unprotectedPositions: number
}
autoSyncTriggered?: boolean
}
// Cooldown to prevent sync spam (5 minutes)
let lastAutoSyncTime = 0
const AUTO_SYNC_COOLDOWN_MS = 5 * 60 * 1000
/**
* Automatically trigger position sync when untracked positions detected
*
* CRITICAL: This prevents the $1,000 loss scenario where Telegram bot
* opens positions but Position Manager never tracks them.
*
* Cooldown: 5 minutes between auto-syncs to prevent spam
*/
async function autoSyncUntrackedPositions(): Promise<boolean> {
const now = Date.now()
// Check cooldown
if (now - lastAutoSyncTime < AUTO_SYNC_COOLDOWN_MS) {
const remainingSeconds = Math.ceil((AUTO_SYNC_COOLDOWN_MS - (now - lastAutoSyncTime)) / 1000)
console.log(`⏳ Auto-sync cooldown active (${remainingSeconds}s remaining)`)
return false
}
try {
console.log('🔄 AUTO-SYNC TRIGGERED: Untracked positions detected, syncing with Drift...')
// Call the sync endpoint internally
const pm = await getInitializedPositionManager()
const driftService = getDriftService()
const driftPositions = await driftService.getAllPositions()
console.log(`📊 Found ${driftPositions.length} positions on Drift`)
let added = 0
for (const driftPos of driftPositions) {
const isTracked = Array.from((pm as any).activeTrades.values()).some(
(t: any) => t.symbol === driftPos.symbol
)
if (!isTracked && Math.abs(driftPos.size) > 0) {
console.log(` Auto-adding ${driftPos.symbol} to Position Manager`)
// Import sync logic
const { syncSinglePosition } = await import('../trading/sync-helper')
await syncSinglePosition(driftPos, pm)
added++
}
}
lastAutoSyncTime = now
console.log(`✅ AUTO-SYNC COMPLETE: Added ${added} position(s) to monitoring`)
return true
} catch (error) {
console.error('❌ Auto-sync failed:', error)
return false
}
}
function calculatePrice(entry: number, percent: number, direction: 'long' | 'short'): number {
return direction === 'long'
? entry * (1 + percent / 100)
: entry * (1 - percent / 100)
}
async function ensureExitOrdersForTrade(
trade: any,
config: ReturnType<typeof getMergedConfig>
): Promise<{ placed: boolean; message?: string; error?: string }> {
try {
const positionSizeUSD = trade.positionSizeUSD || trade.positionSize || 0
if (!positionSizeUSD || !trade.entryPrice) {
return { placed: false, error: 'Missing position size or entry price' }
}
const direction: 'long' | 'short' = trade.direction === 'short' ? 'short' : 'long'
const tp1Price =
trade.takeProfit1Price || calculatePrice(trade.entryPrice, config.takeProfit1Percent, direction)
const tp2Price =
trade.takeProfit2Price || calculatePrice(trade.entryPrice, config.takeProfit2Percent, direction)
const stopLossPrice =
trade.stopLossPrice || calculatePrice(trade.entryPrice, config.stopLossPercent, direction)
const tp1SizePercent = trade.tp1SizePercent ?? config.takeProfit1SizePercent
const tp2SizePercentRaw = trade.tp2SizePercent ?? config.takeProfit2SizePercent ?? 0
const tp2SizePercent = config.useTp2AsTriggerOnly && tp2SizePercentRaw <= 0 ? 0 : tp2SizePercentRaw
const softStopPrice = config.useDualStops
? calculatePrice(trade.entryPrice, config.softStopPercent, direction)
: undefined
const hardStopPrice = config.useDualStops
? calculatePrice(trade.entryPrice, config.hardStopPercent, direction)
: undefined
const { placeExitOrders } = await import('../drift/orders')
const placeResult = await placeExitOrders({
symbol: trade.symbol,
positionSizeUSD,
entryPrice: trade.entryPrice,
tp1Price,
tp2Price,
stopLossPrice,
tp1SizePercent,
tp2SizePercent,
direction,
useDualStops: config.useDualStops,
softStopPrice,
softStopBuffer: config.softStopBuffer,
hardStopPrice,
})
if (!placeResult.success) {
return { placed: false, error: placeResult.error || 'Unknown error placing exit orders' }
}
const signatures = placeResult.signatures || []
const normalizedTp2Percent = tp2SizePercent === undefined ? 100 : Math.max(0, tp2SizePercent)
const tp1USD = (positionSizeUSD * tp1SizePercent) / 100
const remainingAfterTP1 = positionSizeUSD - tp1USD
const tp2USD = (remainingAfterTP1 * normalizedTp2Percent) / 100
let idx = 0
const updateData: any = {}
if (tp1USD > 0 && idx < signatures.length) {
updateData.tp1OrderTx = signatures[idx++]
}
if (normalizedTp2Percent > 0 && idx < signatures.length) {
updateData.tp2OrderTx = signatures[idx++]
}
if (config.useDualStops && softStopPrice && hardStopPrice) {
if (idx < signatures.length) {
updateData.softStopOrderTx = signatures[idx++]
}
if (idx < signatures.length) {
updateData.hardStopOrderTx = signatures[idx++]
}
} else if (idx < signatures.length) {
updateData.slOrderTx = signatures[idx++]
}
const prisma = getPrismaClient()
await prisma.trade.update({ where: { id: trade.id }, data: updateData })
return { placed: true, message: 'Protective exits placed' }
} catch (error) {
return {
placed: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* Check Position Manager health
*
* CRITICAL CHECKS:
* 1. If DB has open trades, Position Manager MUST be monitoring
* 2. If Position Manager has trades, monitoring MUST be active
* 3. All open positions MUST have TP/SL orders on-chain
* 4. Position Manager trade count MUST match Drift position count
*/
export async function checkPositionManagerHealth(): Promise<HealthCheckResult> {
const issues: string[] = []
const warnings: string[] = []
const config = getMergedConfig()
try {
// Get database open trades
const dbTrades = await getOpenTrades()
const dbOpenCount = dbTrades.length
// Get Position Manager state
const pm = await getInitializedPositionManager()
const pmState = (pm as any)
let pmActiveTrades = pmState.activeTrades?.size || 0
let pmMonitoring = pmState.isMonitoring || false
// Get Drift positions
const driftService = getDriftService()
const positions = await driftService.getAllPositions()
const driftPositions = positions.filter(p => Math.abs(p.size) > 0).length
// CRITICAL CHECK #1: DB has open trades but PM not monitoring
if (dbOpenCount > 0 && !pmMonitoring) {
console.log('🛠️ Health monitor: Attempting automatic monitoring restore from DB...')
try {
await pm.initialize(true)
pmActiveTrades = (pm as any).activeTrades?.size || 0
pmMonitoring = (pm as any).isMonitoring || false
} catch (restoreError) {
console.error('❌ Failed to auto-restore monitoring:', restoreError)
}
}
// Re-check after attempted restore
if (dbOpenCount > 0 && !pmMonitoring) {
issues.push(`❌ CRITICAL: ${dbOpenCount} open trades in DB but Position Manager NOT monitoring!`)
issues.push(` This means NO TP/SL protection, NO monitoring, UNCONTROLLED RISK`)
issues.push(` ACTION REQUIRED: Restart container to restore monitoring`)
}
// CRITICAL CHECK #2: PM has trades but not monitoring
if (pmActiveTrades > 0 && !pmMonitoring) {
issues.push(`❌ CRITICAL: Position Manager has ${pmActiveTrades} active trades but monitoring is OFF!`)
issues.push(` This is the $1,000 loss bug - trades "added" but never monitored`)
issues.push(` ACTION REQUIRED: Fix startMonitoring() function`)
}
// CRITICAL CHECK #3: DB vs PM mismatch
if (dbOpenCount !== pmActiveTrades) {
warnings.push(`⚠️ WARNING: DB has ${dbOpenCount} open trades, PM has ${pmActiveTrades} active trades`)
warnings.push(` Possible orphaned position or monitoring not started`)
}
// CRITICAL CHECK #4: PM vs Drift mismatch
if (pmActiveTrades !== driftPositions) {
warnings.push(`⚠️ WARNING: Position Manager has ${pmActiveTrades} trades, Drift has ${driftPositions} positions`)
warnings.push(` Possible untracked position or external closure`)
// AUTO-SYNC: If Drift has MORE positions than PM, sync automatically
if (driftPositions > pmActiveTrades) {
console.log(`🚨 UNTRACKED POSITIONS DETECTED: Drift has ${driftPositions}, PM has ${pmActiveTrades}`)
const synced = await autoSyncUntrackedPositions()
if (synced) {
warnings.push(`✅ AUTO-SYNC: Triggered position sync to restore protection`)
// Re-check PM state after sync
pmActiveTrades = (pm as any).activeTrades?.size || 0
}
}
}
// Check for unprotected positions
let unprotectedPositions = 0
for (const trade of dbTrades) {
const hasDbSignatures = !!(trade.slOrderTx || trade.softStopOrderTx || trade.hardStopOrderTx)
if (!hasDbSignatures) {
unprotectedPositions++
issues.push(`❌ CRITICAL: Position ${trade.symbol} (${trade.id}) has NO STOP LOSS ORDERS!`)
issues.push(` Entry: $${trade.entryPrice}, Size: $${trade.positionSizeUSD}`)
issues.push(` Attempting automatic protective order placement...`)
const remediation = await ensureExitOrdersForTrade(trade, config)
if (remediation.placed) {
warnings.push(`✅ Auto-placed protective exit orders for ${trade.symbol} (${trade.id})`)
} else if (remediation.error) {
issues.push(` ❌ Failed to auto-place exits: ${remediation.error}`)
}
}
if (!trade.tp1OrderTx) {
warnings.push(`⚠️ Position ${trade.symbol} missing TP1 order (not synced)`)
}
if (!trade.tp2OrderTx) {
warnings.push(`⚠️ Position ${trade.symbol} missing TP2 order (not synced)`)
}
}
// BUG #89 FIX PART 3 (Dec 16, 2025): Check for fractional position remnants
// Query database for positions marked as FRACTIONAL_REMNANT in last 24 hours
// These require manual intervention via Drift UI
const prisma = getPrismaClient()
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const fractionalRemnants = await prisma.trade.findMany({
where: {
exitReason: 'FRACTIONAL_REMNANT' as any,
exitTime: { gte: twentyFourHoursAgo }
},
select: {
id: true,
symbol: true,
positionSizeUSD: true,
exitTime: true
}
})
if (fractionalRemnants.length > 0) {
issues.push(`🛑 CRITICAL: ${fractionalRemnants.length} fractional position remnant(s) detected!`)
issues.push(` These are positions that confirmed close but left small remnants on Drift`)
issues.push(` Close attempts exhausted (3 max) - MANUAL INTERVENTION REQUIRED`)
for (const remnant of fractionalRemnants) {
issues.push(`${remnant.symbol}: Size $${remnant.positionSizeUSD.toFixed(2)} (Trade ID: ${remnant.id})`)
issues.push(` Detected at: ${remnant.exitTime?.toISOString()}`)
issues.push(` Action: Close manually via Drift UI at https://app.drift.trade`)
}
}
const isHealthy = issues.length === 0
return {
isHealthy,
issues,
warnings,
info: {
dbOpenTrades: dbOpenCount,
pmActiveTrades,
pmMonitoring,
driftPositions,
unprotectedPositions
}
}
} catch (error) {
issues.push(`❌ Health check failed: ${error instanceof Error ? error.message : String(error)}`)
return {
isHealthy: false,
issues,
warnings,
info: {
dbOpenTrades: 0,
pmActiveTrades: 0,
pmMonitoring: false,
driftPositions: 0,
unprotectedPositions: 0
}
}
}
}
/**
* Start periodic health checks
*/
export function startPositionManagerHealthMonitor(): void {
console.log('🏥 Starting Position Manager health monitor (every 30 seconds)...')
// Initial check
checkPositionManagerHealth().then(result => {
logHealthCheckResult(result)
})
// Periodic checks every 30 seconds
setInterval(async () => {
const result = await checkPositionManagerHealth()
// Only log if there are issues or warnings
if (!result.isHealthy || result.warnings.length > 0) {
logHealthCheckResult(result)
}
}, 30000) // 30 seconds
}
function logHealthCheckResult(result: HealthCheckResult): void {
if (result.isHealthy && result.warnings.length === 0) {
console.log('✅ Position Manager health check PASSED')
console.log(` DB: ${result.info.dbOpenTrades} open | PM: ${result.info.pmActiveTrades} active | Monitoring: ${result.info.pmMonitoring ? 'YES' : 'NO'} | Drift: ${result.info.driftPositions} positions`)
return
}
console.log('\n🏥 POSITION MANAGER HEALTH CHECK REPORT:')
console.log('━'.repeat(80))
if (result.issues.length > 0) {
console.log('\n🔴 CRITICAL ISSUES:')
result.issues.forEach(issue => console.log(issue))
}
if (result.warnings.length > 0) {
console.log('\n⚠ WARNINGS:')
result.warnings.forEach(warning => console.log(warning))
}
console.log('\n📊 SYSTEM STATE:')
console.log(` Database open trades: ${result.info.dbOpenTrades}`)
console.log(` Position Manager active trades: ${result.info.pmActiveTrades}`)
console.log(` Position Manager monitoring: ${result.info.pmMonitoring ? '✅ YES' : '❌ NO'}`)
console.log(` Drift open positions: ${result.info.driftPositions}`)
console.log(` Unprotected positions: ${result.info.unprotectedPositions}`)
console.log('━'.repeat(80))
}