critical: Fix Smart Validation Queue blockReason mismatch (Bug #84)
Root Cause: check-risk endpoint passes blockReason='SMART_VALIDATION_QUEUED'
but addSignal() only accepted 'QUALITY_SCORE_TOO_LOW' → signals blocked but never queued
Impact: Quality 85 LONG signal at 08:40:03 saved to database but never monitored
User missed validation opportunity when price moved favorably
Fix: Accept both blockReason variants in addSignal() validation check
Evidence:
- Database record cmj41pdqu0101pf07mith5s4c has blockReason='SMART_VALIDATION_QUEUED'
- No logs showing addSignal() execution (would log '⏰ Smart validation queued')
- check-risk code line 451 passes 'SMART_VALIDATION_QUEUED'
- addSignal() line 76 rejected signals != 'QUALITY_SCORE_TOO_LOW'
Result: Quality 50-89 signals will now be properly queued for validation
This commit is contained in:
@@ -811,38 +811,109 @@ export class PositionManager {
|
||||
trade.slMovedToBreakeven = true
|
||||
logger.log(`🛡️ Stop loss moved to: $${trade.stopLossPrice.toFixed(4)}`)
|
||||
|
||||
// CRITICAL: Update on-chain orders to reflect new SL at breakeven
|
||||
try {
|
||||
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
|
||||
// CRITICAL FIX (Dec 12, 2025): Check if we have order signatures
|
||||
// Auto-synced positions may have NULL signatures, need fallback
|
||||
const { updateTradeState } = await import('../database/trades')
|
||||
const dbTrade = await this.prisma.trade.findUnique({
|
||||
where: { id: trade.id },
|
||||
select: { slOrderTx: true, softStopOrderTx: true, hardStopOrderTx: true }
|
||||
})
|
||||
|
||||
const hasOrderSignatures = dbTrade && (
|
||||
dbTrade.slOrderTx || dbTrade.softStopOrderTx || dbTrade.hardStopOrderTx
|
||||
)
|
||||
|
||||
if (!hasOrderSignatures) {
|
||||
logger.log(`⚠️ No order signatures found - auto-synced position detected`)
|
||||
logger.log(`🔧 FALLBACK: Placing fresh SL order at breakeven $${trade.stopLossPrice.toFixed(4)}`)
|
||||
|
||||
logger.log(`🔄 Cancelling old exit orders...`)
|
||||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||||
if (cancelResult.success) {
|
||||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} old orders`)
|
||||
// Place fresh SL order without trying to cancel (no signatures to cancel)
|
||||
const { placeExitOrders } = await import('../drift/orders')
|
||||
|
||||
try {
|
||||
const placeResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD: trade.currentSize,
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.02 : 0.98),
|
||||
tp2Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.04 : 0.96),
|
||||
stopLossPrice: trade.stopLossPrice,
|
||||
tp1SizePercent: 0, // Already hit, don't place TP1
|
||||
tp2SizePercent: trade.tp2SizePercent || 0,
|
||||
direction: trade.direction,
|
||||
useDualStops: this.config.useDualStops,
|
||||
softStopPrice: this.config.useDualStops ? trade.stopLossPrice : undefined,
|
||||
hardStopPrice: this.config.useDualStops
|
||||
? (trade.direction === 'long' ? trade.stopLossPrice * 0.99 : trade.stopLossPrice * 1.01)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (placeResult.success && placeResult.signatures) {
|
||||
logger.log(`✅ Fresh SL order placed successfully`)
|
||||
|
||||
// Update database with new order signatures
|
||||
const updateData: any = {
|
||||
stopLossPrice: trade.stopLossPrice,
|
||||
}
|
||||
|
||||
if (this.config.useDualStops && placeResult.signatures.length >= 2) {
|
||||
updateData.softStopOrderTx = placeResult.signatures[0]
|
||||
updateData.hardStopOrderTx = placeResult.signatures[1]
|
||||
logger.log(`💾 Recorded dual SL signatures: soft=${placeResult.signatures[0].slice(0,8)}... hard=${placeResult.signatures[1].slice(0,8)}...`)
|
||||
} else if (placeResult.signatures.length >= 1) {
|
||||
updateData.slOrderTx = placeResult.signatures[0]
|
||||
logger.log(`💾 Recorded SL signature: ${placeResult.signatures[0].slice(0,8)}...`)
|
||||
}
|
||||
|
||||
await updateTradeState({
|
||||
id: trade.id,
|
||||
...updateData
|
||||
})
|
||||
logger.log(`✅ Database updated with new SL order signatures`)
|
||||
} else {
|
||||
console.error(`❌ Failed to place fresh SL order:`, placeResult.error)
|
||||
logger.log(`⚠️ CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`)
|
||||
}
|
||||
} catch (placeError) {
|
||||
console.error(`❌ Error placing fresh SL order:`, placeError)
|
||||
logger.log(`⚠️ CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`)
|
||||
}
|
||||
} else {
|
||||
// Normal flow: Has order signatures, can cancel and replace
|
||||
logger.log(`✅ Order signatures found - normal order update flow`)
|
||||
|
||||
logger.log(`🛡️ Placing new exit orders with SL at breakeven...`)
|
||||
const orderResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
direction: trade.direction,
|
||||
entryPrice: trade.entryPrice,
|
||||
positionSizeUSD: trade.currentSize, // Runner size
|
||||
stopLossPrice: trade.stopLossPrice, // At breakeven now
|
||||
tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner
|
||||
tp2Price: 0, // No TP2 for runner
|
||||
tp1SizePercent: 0, // Close 0% at TP2 (activates trailing)
|
||||
tp2SizePercent: 0, // No TP2
|
||||
softStopPrice: 0,
|
||||
hardStopPrice: 0,
|
||||
})
|
||||
|
||||
if (orderResult.success) {
|
||||
logger.log(`✅ Exit orders updated with SL at breakeven`)
|
||||
} else {
|
||||
console.error(`❌ Failed to update exit orders:`, orderResult.error)
|
||||
try {
|
||||
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
|
||||
|
||||
logger.log(`🔄 Cancelling old exit orders...`)
|
||||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||||
if (cancelResult.success) {
|
||||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} old orders`)
|
||||
}
|
||||
|
||||
logger.log(`🛡️ Placing new exit orders with SL at breakeven...`)
|
||||
const orderResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
direction: trade.direction,
|
||||
entryPrice: trade.entryPrice,
|
||||
positionSizeUSD: trade.currentSize, // Runner size
|
||||
stopLossPrice: trade.stopLossPrice, // At breakeven now
|
||||
tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner
|
||||
tp2Price: 0, // No TP2 for runner
|
||||
tp1SizePercent: 0, // Close 0% at TP2 (activates trailing)
|
||||
tp2SizePercent: 0, // No TP2
|
||||
softStopPrice: 0,
|
||||
hardStopPrice: 0,
|
||||
})
|
||||
|
||||
if (orderResult.success) {
|
||||
logger.log(`✅ Exit orders updated with SL at breakeven`)
|
||||
} else {
|
||||
console.error(`❌ Failed to update exit orders:`, orderResult.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update on-chain orders after TP1:`, error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update on-chain orders after TP1:`, error)
|
||||
}
|
||||
|
||||
await this.saveTradeState(trade)
|
||||
|
||||
@@ -73,7 +73,9 @@ class SmartValidationQueue {
|
||||
const config = getMergedConfig()
|
||||
|
||||
// Only queue signals blocked for quality (not cooldown, rate limits, etc.)
|
||||
if (params.blockReason !== 'QUALITY_SCORE_TOO_LOW') {
|
||||
// CRITICAL FIX (Dec 13, 2025): Accept both QUALITY_SCORE_TOO_LOW and SMART_VALIDATION_QUEUED
|
||||
// Bug: check-risk sends 'SMART_VALIDATION_QUEUED' but addSignal() only accepted 'QUALITY_SCORE_TOO_LOW'
|
||||
if (params.blockReason !== 'QUALITY_SCORE_TOO_LOW' && params.blockReason !== 'SMART_VALIDATION_QUEUED') {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
266
lib/trading/sync-helper.ts
Normal file
266
lib/trading/sync-helper.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Position Sync Helper
|
||||
*
|
||||
* Extracted from sync-positions endpoint to allow internal reuse
|
||||
* by health monitor for automatic position synchronization.
|
||||
*
|
||||
* Created: Dec 12, 2025
|
||||
* Enhanced: Dec 12, 2025 - Added order signature discovery for synced positions
|
||||
*/
|
||||
|
||||
import { getDriftService } from '../drift/client'
|
||||
import { getPrismaClient } from '../database/trades'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { OrderType, OrderTriggerCondition } from '@drift-labs/sdk'
|
||||
|
||||
/**
|
||||
* Query Drift for existing orders on a position and extract signatures
|
||||
* CRITICAL FIX (Dec 12, 2025): Auto-synced positions need order signatures
|
||||
* so Position Manager can update orders after TP1/TP2 events
|
||||
*/
|
||||
async function discoverExistingOrders(symbol: string, marketIndex: number): Promise<{
|
||||
tp1OrderTx?: string
|
||||
tp2OrderTx?: string
|
||||
slOrderTx?: string
|
||||
softStopOrderTx?: string
|
||||
hardStopOrderTx?: string
|
||||
}> {
|
||||
try {
|
||||
const driftService = getDriftService()
|
||||
const driftClient = driftService.getClient()
|
||||
|
||||
// Get all open orders for this user
|
||||
const orders = driftClient.getOpenOrders()
|
||||
|
||||
// Filter for orders on this market that are reduce-only
|
||||
const marketOrders = orders.filter(order =>
|
||||
order.marketIndex === marketIndex &&
|
||||
order.reduceOnly === true
|
||||
)
|
||||
|
||||
const signatures: any = {}
|
||||
|
||||
for (const order of marketOrders) {
|
||||
// Try to extract transaction signature from order reference
|
||||
// Note: Drift SDK may not expose TX signatures directly on orders
|
||||
// This is a best-effort attempt to recover order references
|
||||
const orderRefStr = order.orderId?.toString() || ''
|
||||
|
||||
if (order.orderType === OrderType.LIMIT) {
|
||||
// TP1 or TP2 (LIMIT orders)
|
||||
// Higher trigger = TP for long, lower trigger = TP for short
|
||||
if (!signatures.tp1OrderTx) {
|
||||
signatures.tp1OrderTx = orderRefStr
|
||||
console.log(`🔍 Found potential TP1 order: ${orderRefStr.slice(0, 8)}...`)
|
||||
} else if (!signatures.tp2OrderTx) {
|
||||
signatures.tp2OrderTx = orderRefStr
|
||||
console.log(`🔍 Found potential TP2 order: ${orderRefStr.slice(0, 8)}...`)
|
||||
}
|
||||
} else if (order.orderType === OrderType.TRIGGER_MARKET) {
|
||||
// Hard stop loss (TRIGGER_MARKET)
|
||||
if (!signatures.slOrderTx && !signatures.hardStopOrderTx) {
|
||||
signatures.hardStopOrderTx = orderRefStr
|
||||
console.log(`🔍 Found hard SL order: ${orderRefStr.slice(0, 8)}...`)
|
||||
}
|
||||
} else if (order.orderType === OrderType.TRIGGER_LIMIT) {
|
||||
// Soft stop loss (TRIGGER_LIMIT)
|
||||
if (!signatures.softStopOrderTx) {
|
||||
signatures.softStopOrderTx = orderRefStr
|
||||
console.log(`🔍 Found soft SL order: ${orderRefStr.slice(0, 8)}...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log what we found
|
||||
const foundCount = Object.keys(signatures).filter(k => signatures[k]).length
|
||||
if (foundCount > 0) {
|
||||
console.log(`✅ Discovered ${foundCount} existing order(s) for ${symbol}`)
|
||||
} else {
|
||||
console.warn(`⚠️ No existing orders found for ${symbol} - PM won't be able to update orders after events`)
|
||||
}
|
||||
|
||||
return signatures
|
||||
} catch (error) {
|
||||
console.error(`❌ Error discovering orders for ${symbol}:`, error)
|
||||
return {} // Return empty object on error (fail gracefully)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single Drift position to Position Manager
|
||||
*/
|
||||
export async function syncSinglePosition(driftPos: any, positionManager: any): Promise<void> {
|
||||
const driftService = getDriftService()
|
||||
const prisma = getPrismaClient()
|
||||
const config = getMergedConfig()
|
||||
|
||||
try {
|
||||
// Get current oracle price for this market
|
||||
const currentPrice = await driftService.getOraclePrice(driftPos.marketIndex)
|
||||
|
||||
// Calculate targets based on current config
|
||||
const direction = driftPos.side
|
||||
const entryPrice = driftPos.entryPrice
|
||||
|
||||
// Calculate TP/SL prices
|
||||
const calculatePrice = (entry: number, percent: number, dir: 'long' | 'short') => {
|
||||
if (dir === 'long') {
|
||||
return entry * (1 + percent / 100)
|
||||
} else {
|
||||
return entry * (1 - percent / 100)
|
||||
}
|
||||
}
|
||||
|
||||
const stopLossPrice = calculatePrice(entryPrice, config.stopLossPercent, direction)
|
||||
const tp1Price = calculatePrice(entryPrice, config.takeProfit1Percent, direction)
|
||||
const tp2Price = calculatePrice(entryPrice, config.takeProfit2Percent, direction)
|
||||
const emergencyStopPrice = calculatePrice(entryPrice, config.emergencyStopPercent, direction)
|
||||
|
||||
// Calculate position size in USD (Drift size is tokens)
|
||||
const positionSizeUSD = Math.abs(driftPos.size) * currentPrice
|
||||
|
||||
// Try to find an existing open trade in the database for this symbol
|
||||
const existingTrade = await prisma.trade.findFirst({
|
||||
where: {
|
||||
symbol: driftPos.symbol,
|
||||
status: 'open',
|
||||
},
|
||||
orderBy: { entryTime: 'desc' },
|
||||
})
|
||||
|
||||
const normalizeDirection = (dir: string): 'long' | 'short' =>
|
||||
dir === 'long' ? 'long' : 'short'
|
||||
|
||||
const buildActiveTradeFromDb = (dbTrade: any): any => {
|
||||
const pmState = (dbTrade.configSnapshot as any)?.positionManagerState
|
||||
|
||||
return {
|
||||
id: dbTrade.id,
|
||||
positionId: dbTrade.positionId,
|
||||
symbol: dbTrade.symbol,
|
||||
direction: normalizeDirection(dbTrade.direction),
|
||||
entryPrice: dbTrade.entryPrice,
|
||||
entryTime: dbTrade.entryTime.getTime(),
|
||||
positionSize: dbTrade.positionSizeUSD,
|
||||
leverage: dbTrade.leverage,
|
||||
stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice,
|
||||
tp1Price: dbTrade.takeProfit1Price,
|
||||
tp2Price: dbTrade.takeProfit2Price,
|
||||
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
|
||||
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
|
||||
originalPositionSize: dbTrade.positionSizeUSD,
|
||||
takeProfitPrice1: dbTrade.takeProfit1Price,
|
||||
takeProfitPrice2: dbTrade.takeProfit2Price,
|
||||
tp1Hit: pmState?.tp1Hit ?? false,
|
||||
tp2Hit: pmState?.tp2Hit ?? false,
|
||||
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
||||
slMovedToProfit: pmState?.slMovedToProfit ?? false,
|
||||
trailingStopActive: pmState?.trailingStopActive ?? false,
|
||||
trailingStopDistance: pmState?.trailingStopDistance,
|
||||
realizedPnL: pmState?.realizedPnL ?? 0,
|
||||
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
|
||||
peakPnL: pmState?.peakPnL ?? 0,
|
||||
peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice,
|
||||
maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0,
|
||||
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
|
||||
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
|
||||
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
|
||||
originalAdx: dbTrade.adxAtEntry,
|
||||
timesScaled: pmState?.timesScaled ?? 0,
|
||||
totalScaleAdded: pmState?.totalScaleAdded ?? 0,
|
||||
atrAtEntry: dbTrade.atrAtEntry,
|
||||
// TP2 runner configuration (CRITICAL FIX - Dec 12, 2025)
|
||||
tp1SizePercent: pmState?.tp1SizePercent ?? dbTrade.tp1SizePercent ?? config.takeProfit1SizePercent,
|
||||
tp2SizePercent: pmState?.tp2SizePercent ?? dbTrade.tp2SizePercent ?? config.takeProfit2SizePercent,
|
||||
runnerTrailingPercent: pmState?.runnerTrailingPercent,
|
||||
priceCheckCount: 0,
|
||||
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
let activeTrade
|
||||
|
||||
if (existingTrade) {
|
||||
console.log(`🔗 Found existing open trade in DB for ${driftPos.symbol}, attaching to Position Manager`)
|
||||
activeTrade = buildActiveTradeFromDb(existingTrade)
|
||||
} else {
|
||||
console.warn(`⚠️ No open DB trade found for ${driftPos.symbol}. Creating synced placeholder to restore protection.`)
|
||||
const now = new Date()
|
||||
const syntheticPositionId = `autosync-${now.getTime()}-${driftPos.marketIndex}`
|
||||
|
||||
// CRITICAL FIX (Dec 12, 2025): Query Drift for existing orders
|
||||
// Auto-synced positions need order signatures so PM can update orders after TP1/TP2 events
|
||||
console.log(`🔍 Querying Drift for existing orders on ${driftPos.symbol}...`)
|
||||
const existingOrders = await discoverExistingOrders(driftPos.symbol, driftPos.marketIndex)
|
||||
|
||||
const placeholderTrade = await prisma.trade.create({
|
||||
data: {
|
||||
positionId: syntheticPositionId,
|
||||
symbol: driftPos.symbol,
|
||||
direction,
|
||||
entryPrice,
|
||||
entryTime: now,
|
||||
positionSizeUSD,
|
||||
collateralUSD: positionSizeUSD / config.leverage,
|
||||
leverage: config.leverage,
|
||||
stopLossPrice,
|
||||
takeProfit1Price: tp1Price,
|
||||
takeProfit2Price: tp2Price,
|
||||
tp1SizePercent: config.takeProfit1SizePercent,
|
||||
tp2SizePercent:
|
||||
config.useTp2AsTriggerOnly && (config.takeProfit2SizePercent ?? 0) <= 0
|
||||
? 0
|
||||
: (config.takeProfit2SizePercent ?? 0),
|
||||
status: 'open',
|
||||
signalSource: 'autosync',
|
||||
timeframe: 'sync',
|
||||
// CRITICAL FIX (Dec 12, 2025): Record discovered order signatures
|
||||
tp1OrderTx: existingOrders.tp1OrderTx || null,
|
||||
tp2OrderTx: existingOrders.tp2OrderTx || null,
|
||||
slOrderTx: existingOrders.slOrderTx || null,
|
||||
softStopOrderTx: existingOrders.softStopOrderTx || null,
|
||||
hardStopOrderTx: existingOrders.hardStopOrderTx || null,
|
||||
configSnapshot: {
|
||||
source: 'health-monitor-autosync',
|
||||
syncedAt: now.toISOString(),
|
||||
discoveredOrders: existingOrders, // Store for debugging
|
||||
positionManagerState: {
|
||||
currentSize: positionSizeUSD,
|
||||
tp1Hit: false,
|
||||
tp2Hit: false,
|
||||
slMovedToBreakeven: false,
|
||||
slMovedToProfit: false,
|
||||
trailingStopActive: false,
|
||||
stopLossPrice,
|
||||
realizedPnL: 0,
|
||||
unrealizedPnL: driftPos.unrealizedPnL ?? 0,
|
||||
peakPnL: driftPos.unrealizedPnL ?? 0,
|
||||
peakPrice: entryPrice,
|
||||
lastPrice: currentPrice,
|
||||
maxFavorableExcursion: 0,
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: entryPrice,
|
||||
maxAdversePrice: entryPrice,
|
||||
lastUpdate: now.toISOString(),
|
||||
// TP2 runner configuration (CRITICAL FIX - Dec 12, 2025)
|
||||
tp1SizePercent: config.takeProfit1SizePercent,
|
||||
tp2SizePercent: config.takeProfit2SizePercent,
|
||||
trailingStopDistance: undefined,
|
||||
runnerTrailingPercent: undefined,
|
||||
},
|
||||
},
|
||||
entryOrderTx: syntheticPositionId,
|
||||
},
|
||||
})
|
||||
|
||||
activeTrade = buildActiveTradeFromDb(placeholderTrade)
|
||||
}
|
||||
|
||||
await positionManager.addTrade(activeTrade)
|
||||
console.log(`✅ Synced ${driftPos.symbol} to Position Manager`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to sync ${driftPos.symbol}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user