Implemented comprehensive price tracking for multi-timeframe signal analysis. **Components Added:** - lib/analysis/blocked-signal-tracker.ts - Background job tracking prices - app/api/analytics/signal-tracking/route.ts - Status/metrics endpoint **Features:** - Automatic price tracking at 1min, 5min, 15min, 30min intervals - TP1/TP2/SL hit detection using ATR-based targets - Max favorable/adverse excursion tracking (MFE/MAE) - Analysis completion after 30 minutes - Background job runs every 5 minutes - Entry price captured from signal time **Database Changes:** - Added entryPrice field to BlockedSignal (for price tracking baseline) - Added maxFavorablePrice, maxAdversePrice fields - Added maxFavorableExcursion, maxAdverseExcursion fields **Integration:** - Auto-starts on container startup - Tracks all DATA_COLLECTION_ONLY signals - Uses same TP/SL calculation as live trades (ATR-based) - Calculates profit % based on direction (long vs short) **API Endpoints:** - GET /api/analytics/signal-tracking - View tracking status and metrics - POST /api/analytics/signal-tracking - Manually trigger update (auth required) **Purpose:** Enables data-driven multi-timeframe comparison. After 50+ signals per timeframe, can analyze which timeframe (5min vs 15min vs 1H vs 4H vs Daily) has best win rate, profit potential, and signal quality. **What It Tracks:** - Price at 1min, 5min, 15min, 30min after signal - Would TP1/TP2/SL have been hit? - Maximum profit/loss during 30min window - Complete analysis of signal profitability **How It Works:** 1. Signal comes in (15min, 1H, 4H, Daily) → saved to BlockedSignal 2. Background job runs every 5min 3. Queries current price from Pyth 4. Calculates profit % from entry 5. Checks if TP/SL thresholds crossed 6. Updates MFE/MAE if new highs/lows 7. After 30min, marks analysisComplete=true **Future Analysis:** After 50+ signals per timeframe: - Compare TP1 hit rates across timeframes - Identify which timeframe has highest win rate - Determine optimal signal frequency vs quality trade-off - Switch production to best-performing timeframe User requested: "i want all the bells and whistles. lets make the powerhouse more powerfull. i cant see any reason why we shouldnt"
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
/**
|
|
* Position Manager Startup Initialization
|
|
*
|
|
* Ensures Position Manager starts monitoring on bot startup
|
|
* This prevents orphaned trades when the bot restarts
|
|
*/
|
|
|
|
import { getInitializedPositionManager } from '../trading/position-manager'
|
|
import { initializeDriftService } from '../drift/client'
|
|
import { getPrismaClient } from '../database/trades'
|
|
import { getMarketConfig } from '../../config/trading'
|
|
import { startBlockedSignalTracking } from '../analysis/blocked-signal-tracker'
|
|
|
|
let initStarted = false
|
|
|
|
export async function initializePositionManagerOnStartup() {
|
|
if (initStarted) {
|
|
return
|
|
}
|
|
|
|
initStarted = true
|
|
|
|
console.log('🚀 Initializing Position Manager on startup...')
|
|
|
|
try {
|
|
// CRITICAL: Run database sync validator FIRST to clean up duplicates
|
|
const { validateAllOpenTrades } = await import('../database/sync-validator')
|
|
console.log('🔍 Running database sync validation before Position Manager init...')
|
|
const validationResult = await validateAllOpenTrades()
|
|
|
|
if (validationResult.ghosts > 0) {
|
|
console.log(`✅ Cleaned up ${validationResult.ghosts} ghost/duplicate trades`)
|
|
}
|
|
|
|
// Then validate open trades against Drift positions
|
|
await validateOpenTrades()
|
|
|
|
const manager = await getInitializedPositionManager()
|
|
const status = manager.getStatus()
|
|
|
|
console.log(`✅ Position Manager ready - ${status.activeTradesCount} active trades`)
|
|
|
|
if (status.activeTradesCount > 0) {
|
|
console.log(`📊 Monitoring: ${status.symbols.join(', ')}`)
|
|
}
|
|
|
|
// Start blocked signal price tracking
|
|
console.log('🔬 Starting blocked signal price tracker...')
|
|
startBlockedSignalTracking()
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize Position Manager on startup:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that open trades in database match actual Drift positions
|
|
*
|
|
* CRITICAL FIX (Nov 14, 2025):
|
|
* - Also checks trades marked as "closed" in DB that might still be open on Drift
|
|
* - Happens when close transaction fails but bot marks it as closed anyway
|
|
* - Restores Position Manager tracking for these orphaned positions
|
|
*/
|
|
async function validateOpenTrades() {
|
|
try {
|
|
const prisma = getPrismaClient()
|
|
|
|
// Get both truly open trades AND recently "closed" trades (last 24h)
|
|
// Recently closed trades might still be open if close transaction failed
|
|
// TEMPORARILY REDUCED: Check only last 5 closed trades to avoid rate limiting on startup
|
|
const [openTrades, recentlyClosedTrades] = await Promise.all([
|
|
prisma.trade.findMany({
|
|
where: { status: 'open' },
|
|
orderBy: { entryTime: 'asc' }
|
|
}),
|
|
prisma.trade.findMany({
|
|
where: {
|
|
exitReason: { not: null },
|
|
exitTime: { gte: new Date(Date.now() - 6 * 60 * 60 * 1000) } // Last 6 hours (reduced from 24h)
|
|
},
|
|
orderBy: { exitTime: 'desc' },
|
|
take: 5 // Reduced from 20 to avoid rate limiting
|
|
})
|
|
])
|
|
|
|
const allTradesToCheck = [...openTrades, ...recentlyClosedTrades]
|
|
|
|
if (allTradesToCheck.length === 0) {
|
|
console.log('✅ No open trades to validate')
|
|
return
|
|
}
|
|
|
|
console.log(`🔍 Validating ${openTrades.length} open + ${recentlyClosedTrades.length} recently closed trades against Drift...`)
|
|
|
|
// CRITICAL: Group trades by symbol to handle multiple DB entries for same Drift position
|
|
// This prevents reopening old closed trades when only the most recent should be restored
|
|
const tradesBySymbol = new Map<string, any[]>()
|
|
for (const trade of allTradesToCheck) {
|
|
const existing = tradesBySymbol.get(trade.symbol) || []
|
|
existing.push(trade)
|
|
tradesBySymbol.set(trade.symbol, existing)
|
|
}
|
|
|
|
const driftService = await initializeDriftService()
|
|
const driftPositions = await driftService.getAllPositions() // Get all positions once
|
|
|
|
// Process each symbol's trades (keep only most recent if multiple exist)
|
|
for (const [symbol, trades] of tradesBySymbol) {
|
|
// Sort by creation time, newest first
|
|
trades.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
const mostRecentTrade = trades[0]
|
|
const olderTrades = trades.slice(1)
|
|
|
|
// Close any older trades BEFORE validating the most recent
|
|
for (const oldTrade of olderTrades) {
|
|
if (oldTrade.exitReason === null) {
|
|
console.log(`🗑️ Closing duplicate old trade: ${oldTrade.id} (${symbol}, created ${oldTrade.createdAt.toISOString()})`)
|
|
await prisma.trade.update({
|
|
where: { id: oldTrade.id },
|
|
data: {
|
|
status: 'closed',
|
|
exitTime: new Date(),
|
|
exitReason: 'DUPLICATE_CLEANUP',
|
|
exitPrice: oldTrade.entryPrice,
|
|
realizedPnL: 0,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Now validate only the most recent trade for this symbol
|
|
const trade = mostRecentTrade
|
|
|
|
try {
|
|
const marketConfig = getMarketConfig(trade.symbol)
|
|
|
|
// Find matching Drift position by symbol
|
|
const position = driftPositions.find(p => p.symbol === trade.symbol)
|
|
|
|
if (!position || position.size === 0) {
|
|
// No position on Drift
|
|
if (trade.status === 'open') {
|
|
console.log(`⚠️ PHANTOM TRADE: ${trade.symbol} marked open in DB but not found on Drift`)
|
|
console.log(` 🗑️ Auto-closing phantom trade...`)
|
|
|
|
await prisma.trade.update({
|
|
where: { id: trade.id },
|
|
data: {
|
|
status: 'closed',
|
|
exitTime: new Date(),
|
|
exitReason: 'PHANTOM_TRADE_CLEANUP',
|
|
exitPrice: trade.entryPrice,
|
|
realizedPnL: 0,
|
|
realizedPnLPercent: 0,
|
|
}
|
|
})
|
|
}
|
|
// If already closed in DB and not on Drift, that's correct - skip
|
|
continue
|
|
}
|
|
|
|
// Position EXISTS on Drift
|
|
const driftDirection = position.side.toLowerCase() as 'long' | 'short'
|
|
|
|
if (driftDirection !== trade.direction) {
|
|
console.log(`⚠️ DIRECTION MISMATCH: ${trade.symbol} DB=${trade.direction} Drift=${driftDirection}`)
|
|
continue
|
|
}
|
|
|
|
// CRITICAL: If DB says closed but Drift shows open, restore tracking!
|
|
if (trade.exitReason !== null) {
|
|
console.log(`🔴 CRITICAL: ${trade.symbol} marked as CLOSED in DB but still OPEN on Drift!`)
|
|
console.log(` DB entry: $${trade.entryPrice.toFixed(2)} | Drift entry: $${position.entryPrice.toFixed(2)}`)
|
|
console.log(` DB exit: ${trade.exitReason} at ${trade.exitTime?.toISOString()}`)
|
|
console.log(` Drift: ${position.size} ${trade.symbol} ${driftDirection} @ $${position.entryPrice.toFixed(2)}`)
|
|
console.log(` 🔄 Reopening trade and correcting entry price to match Drift...`)
|
|
|
|
// Calculate position size in USD using Drift's entry price
|
|
const currentPrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
|
|
const positionSizeUSD = position.size * currentPrice
|
|
|
|
await prisma.trade.update({
|
|
where: { id: trade.id },
|
|
data: {
|
|
status: 'open',
|
|
exitReason: null,
|
|
exitTime: null,
|
|
exitPrice: null,
|
|
entryPrice: position.entryPrice, // CRITICAL: Use Drift's actual entry price, not DB value
|
|
positionSizeUSD: positionSizeUSD, // Update to current size (may be runner after TP1)
|
|
// Keep original realizedPnL from partial closes if any
|
|
}
|
|
})
|
|
|
|
console.log(` ✅ Trade restored with corrected entry: $${position.entryPrice.toFixed(2)} (was $${trade.entryPrice.toFixed(2)})`)
|
|
} else {
|
|
console.log(`✅ ${trade.symbol} ${trade.direction}: Position verified on Drift`)
|
|
}
|
|
|
|
// CRITICAL FIX (Nov 16, 2025): Restore missing on-chain orders
|
|
// Ghost position closed at 22:03 because orders were missing after validator cleanup
|
|
// This ensures EVERY verified position has on-chain TP/SL protection
|
|
if (position && Math.abs(position.size) >= 0.01) {
|
|
await restoreOrdersIfMissing(trade, position, driftService, prisma)
|
|
}
|
|
|
|
} catch (posError) {
|
|
console.error(`❌ Error validating trade ${trade.symbol}:`, posError)
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ Error in validateOpenTrades:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore on-chain exit orders if missing (Nov 16, 2025)
|
|
*
|
|
* CRITICAL: After validator cleanups or container restarts, positions may exist
|
|
* on Drift without any on-chain TP/SL orders. This leaves only Position Manager
|
|
* software protection - if bot crashes, position is completely unprotected.
|
|
*
|
|
* This function checks if orders exist and places them if missing.
|
|
*/
|
|
async function restoreOrdersIfMissing(
|
|
trade: any,
|
|
position: any,
|
|
driftService: any,
|
|
prisma: any
|
|
): Promise<void> {
|
|
try {
|
|
// Check if position has any reduce-only orders
|
|
const hasOrders = position.orders && position.orders.length > 0
|
|
|
|
if (hasOrders) {
|
|
console.log(`✅ ${trade.symbol} has ${position.orders.length} on-chain orders - protection active`)
|
|
return // Orders exist, nothing to do
|
|
}
|
|
|
|
console.log(`⚠️ ${trade.symbol} has NO on-chain orders - restoring TP/SL protection...`)
|
|
|
|
// Import order placement function
|
|
const { placeExitOrders } = await import('../drift/orders')
|
|
|
|
// Place exit orders using trade's TP/SL prices
|
|
const result = await placeExitOrders({
|
|
symbol: trade.symbol,
|
|
direction: trade.direction,
|
|
entryPrice: trade.entryPrice,
|
|
tp1Price: trade.takeProfit1Price,
|
|
tp2Price: trade.takeProfit2Price,
|
|
stopLossPrice: trade.stopLossPrice,
|
|
positionSizeUSD: trade.positionSizeUSD,
|
|
tp1SizePercent: 75,
|
|
tp2SizePercent: 0, // TP2-as-runner
|
|
})
|
|
|
|
if (result.success) {
|
|
console.log(`✅ Orders restored for ${trade.symbol}:`)
|
|
console.log(` TP1: $${trade.takeProfit1Price.toFixed(4)} (75%)`)
|
|
console.log(` TP2: $${trade.takeProfit2Price.toFixed(4)} (runner trigger)`)
|
|
console.log(` SL: $${trade.stopLossPrice.toFixed(4)}`)
|
|
console.log(` TX: ${result.signatures?.[0]?.slice(0, 8)}...`)
|
|
|
|
// Update database with order transaction signatures
|
|
await prisma.trade.update({
|
|
where: { id: trade.id },
|
|
data: {
|
|
tp1OrderTx: result.signatures?.[0],
|
|
tp2OrderTx: result.signatures?.[1],
|
|
slOrderTx: result.signatures?.[2],
|
|
}
|
|
})
|
|
} else {
|
|
console.error(`❌ Failed to restore orders for ${trade.symbol}:`, result.error)
|
|
console.error(` 🚨 CRITICAL: Position is unprotected - only Position Manager monitoring active`)
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error restoring orders for ${trade.symbol}:`, error)
|
|
console.error(` 🚨 CRITICAL: Position may be unprotected`)
|
|
}
|
|
}
|