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:
@@ -19,6 +19,12 @@ services:
|
||||
- TELEGRAM_CHAT_ID=579304651
|
||||
- TRADING_BOT_URL=${TRADING_BOT_URL:-http://trading-bot-v4:3000}
|
||||
- API_SECRET_KEY=${API_SECRET_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import sys; sys.exit(0)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- traderv4_trading-net
|
||||
|
||||
|
||||
@@ -28,6 +28,65 @@ export interface HealthCheckResult {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,24 +154,42 @@ export async function checkPositionManagerHealth(): Promise<HealthCheckResult> {
|
||||
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 (no SL/TP orders)
|
||||
// Check for unprotected positions
|
||||
// NOTE: Synced/placeholder positions (signalSource='autosync') have NULL signatures in DB
|
||||
// but orders exist on Drift. Position Manager monitoring provides backup protection.
|
||||
let unprotectedPositions = 0
|
||||
for (const trade of dbTrades) {
|
||||
if (!trade.slOrderTx && !trade.softStopOrderTx && !trade.hardStopOrderTx) {
|
||||
const hasDbSignatures = !!(trade.slOrderTx || trade.softStopOrderTx || trade.hardStopOrderTx)
|
||||
const isSyncedPosition = trade.signalSource === 'autosync' || trade.timeframe === 'sync'
|
||||
|
||||
if (!hasDbSignatures && !isSyncedPosition) {
|
||||
// This is NOT a synced position but has no SL orders - CRITICAL
|
||||
unprotectedPositions++
|
||||
issues.push(`❌ CRITICAL: Position ${trade.symbol} (${trade.id}) has NO STOP LOSS ORDERS!`)
|
||||
issues.push(` Entry: $${trade.entryPrice}, Size: $${trade.positionSizeUSD}`)
|
||||
issues.push(` This is the silent SL placement failure bug`)
|
||||
}
|
||||
|
||||
if (!trade.tp1OrderTx) {
|
||||
warnings.push(`⚠️ Position ${trade.symbol} missing TP1 order`)
|
||||
if (!trade.tp1OrderTx && !isSyncedPosition) {
|
||||
warnings.push(`⚠️ Position ${trade.symbol} missing TP1 order (not synced)`)
|
||||
}
|
||||
|
||||
if (!trade.tp2OrderTx) {
|
||||
warnings.push(`⚠️ Position ${trade.symbol} missing TP2 order`)
|
||||
if (!trade.tp2OrderTx && !isSyncedPosition) {
|
||||
warnings.push(`⚠️ Position ${trade.symbol} missing TP2 order (not synced)`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
82
scripts/place-exit-orders.ts
Normal file
82
scripts/place-exit-orders.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Place fresh TP1 + SL orders for existing position
|
||||
* Does NOT close or modify the position itself
|
||||
*/
|
||||
|
||||
import { initializeDriftService } from '../lib/drift/client'
|
||||
import { placeExitOrders } from '../lib/drift/orders'
|
||||
import { getPythClient } from '../lib/pyth/client'
|
||||
|
||||
async function placeExitOrdersForPosition() {
|
||||
try {
|
||||
console.log('🔧 Initializing services...')
|
||||
|
||||
// Initialize Drift
|
||||
const driftService = await initializeDriftService()
|
||||
const driftClient = driftService.getDriftClient()
|
||||
|
||||
// Get current position
|
||||
const position = await driftService.getPosition(0) // SOL-PERP market index
|
||||
if (!position || position.baseAssetAmount.toString() === '0') {
|
||||
console.error('❌ No open SOL-PERP position found')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`✅ Found position: ${position.baseAssetAmount.toString()} base units`)
|
||||
|
||||
// Get current price
|
||||
const pythClient = getPythClient()
|
||||
const currentPrice = await pythClient.getPrice('SOL-PERP')
|
||||
console.log(`💰 Current SOL price: $${currentPrice}`)
|
||||
|
||||
// Position details from database
|
||||
const entryPrice = 131.7993991266376
|
||||
const positionSizeUSD = 903.1306122
|
||||
const tp1Price = 132.8537943196507
|
||||
const stopLossPrice = 129.822408139738
|
||||
const tp1SizePercent = 60 // Close 60% at TP1
|
||||
|
||||
console.log(`\n📊 Position Configuration:`)
|
||||
console.log(` Entry: $${entryPrice}`)
|
||||
console.log(` Size: $${positionSizeUSD} (${(positionSizeUSD / currentPrice).toFixed(2)} SOL)`)
|
||||
console.log(` TP1: $${tp1Price} (+${((tp1Price / entryPrice - 1) * 100).toFixed(2)}%)`)
|
||||
console.log(` SL: $${stopLossPrice} (${((stopLossPrice / entryPrice - 1) * 100).toFixed(2)}%)`)
|
||||
console.log(` TP1 will close: ${tp1SizePercent}%`)
|
||||
console.log(` Runner: ${100 - tp1SizePercent}% (software-monitored, no on-chain TP2)`)
|
||||
|
||||
// Place orders
|
||||
console.log(`\n🔨 Placing exit orders...`)
|
||||
const result = await placeExitOrders({
|
||||
symbol: 'SOL-PERP',
|
||||
direction: 'long',
|
||||
entryPrice: entryPrice,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
tp1Price: tp1Price,
|
||||
tp1SizePercent: tp1SizePercent,
|
||||
tp2Price: 134.171788310917, // Not used (tp2SizePercent = 0)
|
||||
tp2SizePercent: 0, // No TP2 order (runner only)
|
||||
stopLossPrice: stopLossPrice,
|
||||
useDualStops: false, // Single SL only
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
console.log(`\n✅ Orders placed successfully!`)
|
||||
console.log(` Signatures:`, result.signatures)
|
||||
console.log(`\n📝 Update database with these signatures:`)
|
||||
console.log(` tp1OrderTx: ${result.signatures[0]}`)
|
||||
console.log(` slOrderTx: ${result.signatures[1]}`)
|
||||
console.log(` tp2OrderTx: NULL (runner-only, no on-chain order)`)
|
||||
} else {
|
||||
console.error(`\n❌ Failed to place orders:`, result.error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
placeExitOrdersForPosition()
|
||||
@@ -745,14 +745,14 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
data_age_text = f" ({data_age}s old)" if data_age else ""
|
||||
|
||||
message = (
|
||||
f"🛑 *Analytics suggest NOT entering {direction.upper()} {symbol_info['label']}*\n\n"
|
||||
f"*Reason:* {analytics.get('reason', 'Unknown')}\n"
|
||||
f"*Score:* {analytics.get('score', 0)}/100\n"
|
||||
f"*Data:* {data_icon} {data_source}{data_age_text}\n\n"
|
||||
f"Use `{text} --force` to override"
|
||||
f"🛑 Analytics suggest NOT entering {direction.upper()} {symbol_info['label']}\n\n"
|
||||
f"Reason: {analytics.get('reason', 'Unknown')}\n"
|
||||
f"Score: {analytics.get('score', 0)}/100\n"
|
||||
f"Data: {data_icon} {data_source}{data_age_text}\n\n"
|
||||
f"Use '{text} --force' to override"
|
||||
)
|
||||
|
||||
await update.message.reply_text(message, parse_mode='Markdown')
|
||||
await update.message.reply_text(message)
|
||||
print(f"❌ Trade blocked by analytics (score: {analytics.get('score')})", flush=True)
|
||||
return
|
||||
|
||||
@@ -762,12 +762,12 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
data_age_text = f" ({data_age}s old)" if data_age else ""
|
||||
|
||||
confirm_message = (
|
||||
f"✅ *Analytics check passed ({analytics.get('score')}/100)*\n"
|
||||
f"✅ Analytics check passed ({analytics.get('score')}/100)\n"
|
||||
f"Data: {data_source}{data_age_text}\n"
|
||||
f"Proceeding with {direction.upper()} {symbol_info['label']}..."
|
||||
)
|
||||
|
||||
await update.message.reply_text(confirm_message, parse_mode='Markdown')
|
||||
await update.message.reply_text(confirm_message)
|
||||
print(f"✅ Analytics passed (score: {analytics.get('score')})", flush=True)
|
||||
else:
|
||||
# Analytics endpoint failed - proceed with trade (fail-open)
|
||||
@@ -783,9 +783,8 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
|
||||
# Send waiting message to user
|
||||
await update.message.reply_text(
|
||||
f"⏳ *Waiting for next 1-minute datapoint...*\n"
|
||||
f"Will execute with fresh ATR (max 90s)",
|
||||
parse_mode='Markdown'
|
||||
f"⏳ Waiting for next 1-minute datapoint...\n"
|
||||
f"Will execute with fresh ATR (max 90s)"
|
||||
)
|
||||
|
||||
# Poll for fresh data (new timestamp = new datapoint arrived)
|
||||
@@ -813,29 +812,26 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
print(f"✅ Using fresh metrics: ATR={metrics['atr']:.4f}, ADX={metrics['adx']:.1f}, RSI={metrics['rsi']:.1f} ({data_age}s old)", flush=True)
|
||||
|
||||
await update.message.reply_text(
|
||||
f"✅ *Fresh data received*\n"
|
||||
f"✅ Fresh data received\n"
|
||||
f"ATR: {metrics['atr']:.4f} | ADX: {metrics['adx']:.1f} | RSI: {metrics['rsi']:.1f}\n"
|
||||
f"Executing {direction.upper()} {symbol_info['label']}...",
|
||||
parse_mode='Markdown'
|
||||
f"Executing {direction.upper()} {symbol_info['label']}..."
|
||||
)
|
||||
else:
|
||||
print(f"⚠️ Fresh data invalid (ATR={fresh_atr}), using preset metrics", flush=True)
|
||||
|
||||
await update.message.reply_text(
|
||||
f"⚠️ *Fresh data invalid*\n"
|
||||
f"⚠️ Fresh data invalid\n"
|
||||
f"Using preset ATR: {metrics['atr']}\n"
|
||||
f"Executing {direction.upper()} {symbol_info['label']}...",
|
||||
parse_mode='Markdown'
|
||||
f"Executing {direction.upper()} {symbol_info['label']}..."
|
||||
)
|
||||
else:
|
||||
# Timeout - fallback to preset with warning
|
||||
print(f"⚠️ Timeout waiting for fresh data - using preset metrics: ATR={metrics['atr']}", flush=True)
|
||||
|
||||
await update.message.reply_text(
|
||||
f"⚠️ *Timeout waiting for fresh data*\n"
|
||||
f"⚠️ Timeout waiting for fresh data\n"
|
||||
f"Using preset ATR: {metrics['atr']}\n"
|
||||
f"Executing {direction.upper()} {symbol_info['label']}...",
|
||||
parse_mode='Markdown'
|
||||
f"Executing {direction.upper()} {symbol_info['label']}..."
|
||||
)
|
||||
|
||||
# Execute the trade with fresh or fallback metrics
|
||||
@@ -865,17 +861,22 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
|
||||
# Parse JSON even for error responses to get detailed error messages
|
||||
try:
|
||||
print(f"🔍 Parsing JSON response...", flush=True)
|
||||
data = response.json()
|
||||
except Exception:
|
||||
print(f"✅ JSON parsed successfully", flush=True)
|
||||
except Exception as e:
|
||||
print(f"❌ JSON parse error: {e}", flush=True)
|
||||
await update.message.reply_text(f"❌ Execution error ({response.status_code})")
|
||||
return
|
||||
|
||||
if not data.get('success'):
|
||||
# CRITICAL: Show detailed error message (may contain "CLOSE POSITION MANUALLY")
|
||||
message = data.get('message') or data.get('error') or 'Trade rejected'
|
||||
print(f"❌ Trade failed: {message}", flush=True)
|
||||
await update.message.reply_text(f"❌ {message}")
|
||||
return
|
||||
|
||||
print(f"✅ Trade success, extracting data...", flush=True)
|
||||
entry_price = data.get('entryPrice')
|
||||
notional = data.get('positionSize')
|
||||
leverage = data.get('leverage')
|
||||
@@ -902,12 +903,15 @@ async def manual_trade_handler(update: Update, context: ContextTypes.DEFAULT_TYP
|
||||
f"TP1: {tp1_text}\nTP2: {tp2_text}\nSL: {sl_text}"
|
||||
)
|
||||
|
||||
print(f"📤 Sending success message to user...", flush=True)
|
||||
await update.message.reply_text(success_message)
|
||||
print(f"✅ Success message sent!", flush=True)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"❌ Manual trade failed: {exc}", flush=True)
|
||||
await update.message.reply_text(f"❌ Error: {exc}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Start the bot"""
|
||||
|
||||
@@ -929,7 +933,7 @@ async def main():
|
||||
# Create application
|
||||
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
|
||||
|
||||
# Add command handlers
|
||||
# Add handlers
|
||||
application.add_handler(CommandHandler("help", help_command))
|
||||
application.add_handler(CommandHandler("status", status_command))
|
||||
application.add_handler(CommandHandler("close", close_command))
|
||||
@@ -951,10 +955,7 @@ async def main():
|
||||
manual_trade_handler,
|
||||
))
|
||||
|
||||
# Initialize the application first
|
||||
await application.initialize()
|
||||
|
||||
# Register bot commands for autocomplete (works in Telegram AND Matrix bridges)
|
||||
# Set bot commands for autocomplete
|
||||
commands = [
|
||||
BotCommand("help", "Show all available commands"),
|
||||
BotCommand("status", "Show open positions"),
|
||||
@@ -970,23 +971,24 @@ async def main():
|
||||
BotCommand("sellfart", "Sell FARTCOIN (shortcut)"),
|
||||
]
|
||||
await application.bot.set_my_commands(commands)
|
||||
print("✅ Bot commands registered for autocomplete (Telegram + Matrix)", flush=True)
|
||||
print("✅ Bot commands registered for autocomplete", flush=True)
|
||||
|
||||
# Start polling
|
||||
print("\n🤖 Bot ready! Send commands to your Telegram.\n", flush=True)
|
||||
|
||||
# Start the bot with proper async pattern
|
||||
await application.initialize()
|
||||
await application.start()
|
||||
await application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
|
||||
|
||||
# Run until stopped
|
||||
# Keep running until stopped
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Cleanup
|
||||
await application.updater.stop()
|
||||
await application.stop()
|
||||
await application.shutdown()
|
||||
finally:
|
||||
await application.updater.stop()
|
||||
await application.stop()
|
||||
await application.shutdown()
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
|
||||
Reference in New Issue
Block a user