BUG #92: Exit orders (TP1, TP2, SL) had different token sizes than position - Position: 142.91 SOL but TP1=140.87 SOL, SL=147.03 SOL (WRONG) - Root cause: usdToBase() calculated tokens as USD/price per order - Each exit order price produced different token amounts FIX: Pass actual token count via positionSizeTokens parameter - Added positionSizeTokens to PlaceExitOrdersOptions interface - Added tokensToBase() helper (tokens * 1e9 directly) - All exit sections now use token-based calculation when available Files updated to pass positionSizeTokens: - app/api/trading/execute/route.ts: openResult.fillSize - lib/trading/smart-entry-timer.ts: openResult.fillSize - lib/trading/sync-helper.ts: Math.abs(driftPos.size) - lib/trading/position-manager.ts: Math.abs(position.size) + fetch patterns - lib/startup/init-position-manager.ts: Math.abs(position.size) - lib/health/position-manager-health.ts: Drift position fetch + token size Result: When position = X tokens, ALL exit orders close portions of X tokens - TP1: X * tp1SizePercent / 100 tokens - TP2: remaining * tp2SizePercent / 100 tokens - SL: X tokens (full position) Backward compatible: Falls back to USD calculation if positionSizeTokens not provided
781 lines
28 KiB
TypeScript
781 lines
28 KiB
TypeScript
/**
|
||
* Smart Entry Timer Service
|
||
*
|
||
* Implements Phase 2: Smart Entry Timing
|
||
* Waits up to 2 minutes for favorable pullback after signal arrival
|
||
*
|
||
* Strategy:
|
||
* - LONG: Wait for 0.15-0.5% dip below signal price
|
||
* - SHORT: Wait for 0.15-0.5% bounce above signal price
|
||
* - Validate ADX hasn't dropped >2 points (trend still strong)
|
||
* - Timeout at 2 minutes → execute at whatever price
|
||
*
|
||
* Expected improvement: 0.2-0.5% per trade = $1,600-4,000 over 100 trades
|
||
*/
|
||
|
||
import { getMarketDataCache } from './market-data-cache'
|
||
import { logger } from '../utils/logger'
|
||
import { getPythPriceMonitor } from '../pyth/price-monitor'
|
||
|
||
export interface QueuedSignal {
|
||
id: string
|
||
symbol: string
|
||
direction: 'long' | 'short'
|
||
signalPrice: number
|
||
signalTime: number
|
||
receivedAt: number
|
||
expiresAt: number
|
||
|
||
// Pullback targets
|
||
targetPullbackMin: number // 0.15%
|
||
targetPullbackMax: number // 0.50%
|
||
|
||
// ADX validation
|
||
signalADX: number
|
||
adxTolerance: number // 2 points
|
||
|
||
// Original signal data (for execution)
|
||
originalSignalData: {
|
||
atr?: number
|
||
adx?: number
|
||
rsi?: number
|
||
volumeRatio?: number
|
||
pricePosition?: number
|
||
indicatorVersion?: string
|
||
}
|
||
|
||
// Tracking
|
||
status: 'pending' | 'executed' | 'cancelled' | 'expired'
|
||
checksPerformed: number
|
||
bestPriceObserved: number
|
||
executedAt?: number
|
||
executedPrice?: number
|
||
executionReason?: 'pullback_confirmed' | 'timeout' | 'manual_override'
|
||
|
||
// Quality tracking
|
||
qualityScore: number
|
||
|
||
// Position sizing (passed from execute endpoint)
|
||
positionSize?: number // USD amount for position
|
||
leverage?: number // Leverage for position
|
||
positionSizeUSD?: number // Alternative field name used by execute endpoint
|
||
}
|
||
|
||
interface SmartEntryConfig {
|
||
enabled: boolean
|
||
maxWaitMs: number // 2 minutes
|
||
pullbackMin: number // 0.15%
|
||
pullbackMax: number // 0.50%
|
||
adxTolerance: number // 2 points
|
||
monitorIntervalMs: number // 15 seconds
|
||
}
|
||
|
||
export class SmartEntryTimer {
|
||
private queuedSignals: Map<string, QueuedSignal> = new Map()
|
||
private monitoringInterval: NodeJS.Timeout | null = null
|
||
private config: SmartEntryConfig
|
||
|
||
constructor() {
|
||
// Load configuration from ENV
|
||
this.config = {
|
||
enabled: process.env.SMART_ENTRY_ENABLED === 'true',
|
||
maxWaitMs: parseInt(process.env.SMART_ENTRY_MAX_WAIT_MS || '120000'),
|
||
pullbackMin: parseFloat(process.env.SMART_ENTRY_PULLBACK_MIN || '0.15'),
|
||
pullbackMax: parseFloat(process.env.SMART_ENTRY_PULLBACK_MAX || '0.50'),
|
||
adxTolerance: parseFloat(process.env.SMART_ENTRY_ADX_TOLERANCE || '2'),
|
||
monitorIntervalMs: 15000 // 15 seconds
|
||
}
|
||
|
||
logger.log('💡 Smart Entry Timer initialized:', {
|
||
enabled: this.config.enabled,
|
||
maxWait: `${this.config.maxWaitMs / 1000}s`,
|
||
pullback: `${this.config.pullbackMin}-${this.config.pullbackMax}%`,
|
||
adxTolerance: `${this.config.adxTolerance} points`
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Queue a signal for smart entry timing
|
||
*/
|
||
queueSignal(signalData: {
|
||
symbol: string
|
||
direction: 'long' | 'short'
|
||
signalPrice: number
|
||
atr?: number
|
||
adx?: number
|
||
rsi?: number
|
||
volumeRatio?: number
|
||
pricePosition?: number
|
||
indicatorVersion?: string
|
||
qualityScore: number
|
||
positionSizeUSD?: number // CRITICAL FIX (Dec 13, 2025): Store calculated USD size
|
||
leverage?: number // CRITICAL FIX (Dec 13, 2025): Store calculated leverage
|
||
}): QueuedSignal {
|
||
const now = Date.now()
|
||
const signal: QueuedSignal = {
|
||
id: `${signalData.symbol}-${now}`,
|
||
symbol: signalData.symbol,
|
||
direction: signalData.direction,
|
||
signalPrice: signalData.signalPrice,
|
||
signalTime: now,
|
||
receivedAt: now,
|
||
expiresAt: now + this.config.maxWaitMs,
|
||
targetPullbackMin: this.config.pullbackMin,
|
||
targetPullbackMax: this.config.pullbackMax,
|
||
signalADX: signalData.adx || 25, // Default if not provided
|
||
adxTolerance: this.config.adxTolerance,
|
||
originalSignalData: {
|
||
atr: signalData.atr,
|
||
adx: signalData.adx,
|
||
rsi: signalData.rsi,
|
||
volumeRatio: signalData.volumeRatio,
|
||
pricePosition: signalData.pricePosition,
|
||
indicatorVersion: signalData.indicatorVersion
|
||
},
|
||
status: 'pending',
|
||
checksPerformed: 0,
|
||
bestPriceObserved: signalData.signalPrice,
|
||
qualityScore: signalData.qualityScore,
|
||
positionSizeUSD: signalData.positionSizeUSD, // Store calculated size
|
||
leverage: signalData.leverage, // Store calculated leverage
|
||
}
|
||
|
||
this.queuedSignals.set(signal.id, signal)
|
||
|
||
logger.log(`📥 Smart Entry: Queued signal ${signal.id}`)
|
||
logger.log(` ${signal.direction.toUpperCase()} ${signal.symbol} @ $${signal.signalPrice.toFixed(2)}`)
|
||
logger.log(` Target pullback: ${this.config.pullbackMin}-${this.config.pullbackMax}%`)
|
||
logger.log(` Max wait: ${this.config.maxWaitMs / 1000}s`)
|
||
|
||
// Start monitoring if not already running
|
||
if (!this.monitoringInterval) {
|
||
this.startMonitoring()
|
||
}
|
||
|
||
return signal
|
||
}
|
||
|
||
/**
|
||
* Start monitoring loop
|
||
*/
|
||
private startMonitoring(): void {
|
||
if (this.monitoringInterval) return
|
||
|
||
logger.log(`👁️ Smart Entry: Starting monitoring loop (${this.config.monitorIntervalMs / 1000}s interval)`)
|
||
|
||
this.monitoringInterval = setInterval(() => {
|
||
this.checkAllSignals()
|
||
}, this.config.monitorIntervalMs)
|
||
}
|
||
|
||
/**
|
||
* Stop monitoring loop
|
||
*/
|
||
private stopMonitoring(): void {
|
||
if (this.monitoringInterval) {
|
||
clearInterval(this.monitoringInterval)
|
||
this.monitoringInterval = null
|
||
logger.log(`⏸️ Smart Entry: Monitoring stopped (no active signals)`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check all queued signals
|
||
*/
|
||
private async checkAllSignals(): Promise<void> {
|
||
const now = Date.now()
|
||
|
||
for (const [id, signal] of this.queuedSignals) {
|
||
if (signal.status !== 'pending') continue
|
||
|
||
signal.checksPerformed++
|
||
|
||
// Check for timeout
|
||
if (now >= signal.expiresAt) {
|
||
logger.log(`⏰ Smart Entry: Timeout for ${signal.symbol} (waited ${this.config.maxWaitMs / 1000}s)`)
|
||
const priceMonitor = getPythPriceMonitor()
|
||
const latestPrice = priceMonitor.getCachedPrice(signal.symbol)
|
||
const currentPrice = latestPrice?.price || signal.signalPrice
|
||
|
||
await this.executeSignal(signal, currentPrice, 'timeout')
|
||
continue
|
||
}
|
||
|
||
// Check for optimal entry
|
||
await this.checkSignalForEntry(signal)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if signal should be executed now
|
||
*/
|
||
private async checkSignalForEntry(signal: QueuedSignal): Promise<void> {
|
||
// Get current price
|
||
const priceMonitor = getPythPriceMonitor()
|
||
const latestPrice = priceMonitor.getCachedPrice(signal.symbol)
|
||
|
||
if (!latestPrice || !latestPrice.price) {
|
||
logger.log(`⚠️ Smart Entry: No price available for ${signal.symbol}, skipping check`)
|
||
return
|
||
}
|
||
|
||
const currentPrice = latestPrice.price
|
||
|
||
// Update best price
|
||
if (signal.direction === 'long' && currentPrice < signal.bestPriceObserved) {
|
||
signal.bestPriceObserved = currentPrice
|
||
} else if (signal.direction === 'short' && currentPrice > signal.bestPriceObserved) {
|
||
signal.bestPriceObserved = currentPrice
|
||
}
|
||
|
||
// Calculate pullback magnitude
|
||
let pullbackMagnitude: number
|
||
if (signal.direction === 'long') {
|
||
// LONG: Want price BELOW signal (pullback = dip)
|
||
pullbackMagnitude = ((signal.signalPrice - currentPrice) / signal.signalPrice) * 100
|
||
} else {
|
||
// SHORT: Want price ABOVE signal (pullback = bounce)
|
||
pullbackMagnitude = ((currentPrice - signal.signalPrice) / signal.signalPrice) * 100
|
||
}
|
||
|
||
// Log check
|
||
logger.log(`🔍 Smart Entry: Checking ${signal.symbol} (check #${signal.checksPerformed})`)
|
||
logger.log(` Signal: $${signal.signalPrice.toFixed(2)} → Current: $${currentPrice.toFixed(2)}`)
|
||
logger.log(` Pullback: ${pullbackMagnitude.toFixed(2)}% (target ${signal.targetPullbackMin}-${signal.targetPullbackMax}%)`)
|
||
|
||
// Check if pullback is in target range
|
||
if (pullbackMagnitude < signal.targetPullbackMin) {
|
||
logger.log(` ⏳ Waiting for pullback (${pullbackMagnitude.toFixed(2)}% < ${signal.targetPullbackMin}%)`)
|
||
return
|
||
}
|
||
|
||
if (pullbackMagnitude > signal.targetPullbackMax) {
|
||
logger.log(` ⚠️ Pullback too large (${pullbackMagnitude.toFixed(2)}% > ${signal.targetPullbackMax}%), might be reversal - waiting`)
|
||
return
|
||
}
|
||
|
||
// ============================================================
|
||
// PHASE 7.2: REAL-TIME QUALITY VALIDATION (Nov 27, 2025)
|
||
// ============================================================
|
||
// Re-validate signal quality before entry using fresh market data
|
||
// Prevents execution if conditions degraded during wait period
|
||
|
||
const marketCache = getMarketDataCache()
|
||
const latestMetrics = marketCache.get(signal.symbol)
|
||
|
||
if (latestMetrics) {
|
||
const now = Date.now()
|
||
const dataAge = (now - latestMetrics.timestamp) / 1000
|
||
|
||
logger.log(` 📊 Real-time validation (data age: ${dataAge.toFixed(0)}s):`)
|
||
|
||
// 1. ADX degradation check (original logic)
|
||
if (latestMetrics.adx) {
|
||
const adxDrop = signal.signalADX - latestMetrics.adx
|
||
|
||
if (adxDrop > signal.adxTolerance) {
|
||
logger.log(` ❌ ADX degraded: ${signal.signalADX.toFixed(1)} → ${latestMetrics.adx.toFixed(1)} (dropped ${adxDrop.toFixed(1)} points, max ${signal.adxTolerance})`)
|
||
signal.status = 'cancelled'
|
||
signal.executionReason = 'manual_override'
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(` 🚫 Signal cancelled: ADX degradation exceeded tolerance`)
|
||
return
|
||
}
|
||
|
||
logger.log(` ✅ ADX: ${signal.signalADX.toFixed(1)} → ${latestMetrics.adx.toFixed(1)} (within tolerance)`)
|
||
}
|
||
|
||
// 2. Volume degradation check (NEW)
|
||
// If volume drops significantly, momentum may be fading
|
||
if (signal.originalSignalData.volumeRatio && latestMetrics.volumeRatio) {
|
||
const originalVolume = signal.originalSignalData.volumeRatio
|
||
const currentVolume = latestMetrics.volumeRatio
|
||
const volumeDrop = ((originalVolume - currentVolume) / originalVolume) * 100
|
||
|
||
// Cancel if volume dropped >40%
|
||
if (volumeDrop > 40) {
|
||
logger.log(` ❌ Volume collapsed: ${originalVolume.toFixed(2)}x → ${currentVolume.toFixed(2)}x (${volumeDrop.toFixed(0)}% drop)`)
|
||
signal.status = 'cancelled'
|
||
signal.executionReason = 'manual_override'
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(` 🚫 Signal cancelled: Volume degradation - momentum fading`)
|
||
return
|
||
}
|
||
|
||
logger.log(` ✅ Volume: ${originalVolume.toFixed(2)}x → ${currentVolume.toFixed(2)}x`)
|
||
}
|
||
|
||
// 3. RSI reversal check (NEW)
|
||
// If RSI crossed into opposite territory, trend may be reversing
|
||
if (signal.originalSignalData.rsi && latestMetrics.rsi) {
|
||
const originalRSI = signal.originalSignalData.rsi
|
||
const currentRSI = latestMetrics.rsi
|
||
|
||
if (signal.direction === 'long') {
|
||
// LONG: Cancel if RSI dropped into oversold (<30)
|
||
if (originalRSI >= 40 && currentRSI < 30) {
|
||
logger.log(` ❌ RSI collapsed: ${originalRSI.toFixed(1)} → ${currentRSI.toFixed(1)} (now oversold)`)
|
||
signal.status = 'cancelled'
|
||
signal.executionReason = 'manual_override'
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(` 🚫 Signal cancelled: RSI reversal - trend weakening`)
|
||
return
|
||
}
|
||
} else {
|
||
// SHORT: Cancel if RSI rose into overbought (>70)
|
||
if (originalRSI <= 60 && currentRSI > 70) {
|
||
logger.log(` ❌ RSI spiked: ${originalRSI.toFixed(1)} → ${currentRSI.toFixed(1)} (now overbought)`)
|
||
signal.status = 'cancelled'
|
||
signal.executionReason = 'manual_override'
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(` 🚫 Signal cancelled: RSI reversal - trend weakening`)
|
||
return
|
||
}
|
||
}
|
||
|
||
logger.log(` ✅ RSI: ${originalRSI.toFixed(1)} → ${currentRSI.toFixed(1)}`)
|
||
}
|
||
|
||
// 4. MAGAP divergence check (NEW)
|
||
// If MA gap widened in opposite direction, structure changing
|
||
if (latestMetrics.maGap !== undefined) {
|
||
const currentMAGap = latestMetrics.maGap
|
||
|
||
if (signal.direction === 'long' && currentMAGap < -1.0) {
|
||
// LONG but MAs now bearish diverging
|
||
logger.log(` ❌ MA structure bearish: MAGAP ${currentMAGap.toFixed(2)}% (death cross accelerating)`)
|
||
signal.status = 'cancelled'
|
||
signal.executionReason = 'manual_override'
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(` 🚫 Signal cancelled: MA structure turned bearish`)
|
||
return
|
||
}
|
||
|
||
if (signal.direction === 'short' && currentMAGap > 1.0) {
|
||
// SHORT but MAs now bullish diverging
|
||
logger.log(` ❌ MA structure bullish: MAGAP ${currentMAGap.toFixed(2)}% (golden cross accelerating)`)
|
||
signal.status = 'cancelled'
|
||
signal.executionReason = 'manual_override'
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(` 🚫 Signal cancelled: MA structure turned bullish`)
|
||
return
|
||
}
|
||
|
||
logger.log(` ✅ MAGAP: ${currentMAGap.toFixed(2)}%`)
|
||
}
|
||
|
||
logger.log(` ✅ All real-time validations passed - signal quality maintained`)
|
||
} else {
|
||
logger.log(` ⚠️ No fresh market data available - proceeding with original signal`)
|
||
}
|
||
|
||
// All conditions met - execute!
|
||
logger.log(`✅ Smart Entry: OPTIMAL ENTRY CONFIRMED`)
|
||
logger.log(` Pullback: ${pullbackMagnitude.toFixed(2)}% (target ${signal.targetPullbackMin}-${signal.targetPullbackMax}%)`)
|
||
logger.log(` Price improvement: $${signal.signalPrice.toFixed(2)} → $${currentPrice.toFixed(2)}`)
|
||
|
||
await this.executeSignal(signal, currentPrice, 'pullback_confirmed')
|
||
}
|
||
|
||
/**
|
||
* Execute the trade with actual entry price
|
||
*/
|
||
private async executeSignal(
|
||
signal: QueuedSignal,
|
||
entryPrice: number,
|
||
reason: 'pullback_confirmed' | 'timeout' | 'manual_override'
|
||
): Promise<void> {
|
||
signal.status = 'executed'
|
||
signal.executedAt = Date.now()
|
||
signal.executedPrice = entryPrice
|
||
signal.executionReason = reason
|
||
|
||
const improvement = ((signal.signalPrice - entryPrice) / signal.signalPrice) * 100
|
||
const improvementDirection = signal.direction === 'long' ? improvement : -improvement
|
||
|
||
logger.log(`🎯 Smart Entry: EXECUTING ${signal.direction.toUpperCase()} ${signal.symbol}`)
|
||
logger.log(` Signal Price: $${signal.signalPrice.toFixed(2)}`)
|
||
logger.log(` Entry Price: $${entryPrice.toFixed(2)}`)
|
||
logger.log(` Improvement: ${improvementDirection >= 0 ? '+' : ''}${improvementDirection.toFixed(2)}%`)
|
||
|
||
// Execute the actual trade through Drift
|
||
try {
|
||
const { openPosition, placeExitOrders } = await import('../drift/orders')
|
||
const { initializeDriftService } = await import('../drift/client')
|
||
const { createTrade } = await import('../database/trades')
|
||
const { getInitializedPositionManager } = await import('./position-manager')
|
||
const { getMergedConfig, getActualPositionSizeForSymbol, SUPPORTED_MARKETS } = await import('../../config/trading')
|
||
|
||
// Get Drift service
|
||
const driftService = await initializeDriftService()
|
||
if (!driftService) {
|
||
console.error('❌ Smart Entry: Drift service not available')
|
||
return
|
||
}
|
||
|
||
// Get market config
|
||
const marketConfig = SUPPORTED_MARKETS[signal.symbol]
|
||
if (!marketConfig) {
|
||
console.error(`❌ Smart Entry: No market config for ${signal.symbol}`)
|
||
return
|
||
}
|
||
|
||
// CRITICAL FIX (Dec 13, 2025): Use stored positionSizeUSD from queue time
|
||
// Bug: Recalculating fresh causes $10.40 instead of $435 (97.6% size loss)
|
||
// Fix: Use stored values when available, only recalculate as fallback
|
||
const config = getMergedConfig()
|
||
let positionSizeUSD: number
|
||
let leverage: number
|
||
|
||
if (signal.positionSizeUSD && signal.leverage) {
|
||
// Use stored values from queue time (FIX for timeout sizing bug)
|
||
positionSizeUSD = signal.positionSizeUSD
|
||
leverage = signal.leverage
|
||
logger.log(` Using stored position size: $${positionSizeUSD.toFixed(2)} at ${leverage}x leverage (queued values)`)
|
||
} else {
|
||
// Fallback: Recalculate (backwards compatibility for old queued signals)
|
||
const sizing = await getActualPositionSizeForSymbol(
|
||
signal.symbol,
|
||
config,
|
||
signal.qualityScore
|
||
)
|
||
positionSizeUSD = sizing.size
|
||
leverage = sizing.leverage
|
||
logger.log(` Recalculated position size: $${positionSizeUSD.toFixed(2)} at ${leverage}x leverage (fallback)`)
|
||
}
|
||
|
||
logger.log(` Opening position: $${positionSizeUSD.toFixed(2)} at ${leverage}x leverage`)
|
||
|
||
// Open position
|
||
const openResult = await openPosition({
|
||
symbol: signal.symbol,
|
||
direction: signal.direction,
|
||
sizeUSD: positionSizeUSD,
|
||
slippageTolerance: 1.0 // 1% slippage tolerance
|
||
})
|
||
|
||
if (!openResult.success || !openResult.transactionSignature) {
|
||
console.error(`❌ Smart Entry: Position open failed - ${openResult.error}`)
|
||
return
|
||
}
|
||
|
||
const fillPrice = openResult.fillPrice!
|
||
logger.log(`✅ Smart Entry: Position opened at $${fillPrice.toFixed(2)}`)
|
||
|
||
// Calculate TP/SL prices
|
||
let tp1Percent = config.takeProfit1Percent
|
||
let tp2Percent = config.takeProfit2Percent
|
||
let slPercent = config.stopLossPercent
|
||
|
||
if (config.useAtrBasedTargets && signal.originalSignalData.atr && signal.originalSignalData.atr > 0) {
|
||
// ATR-based targets
|
||
tp1Percent = this.calculatePercentFromAtr(
|
||
signal.originalSignalData.atr,
|
||
fillPrice,
|
||
config.atrMultiplierTp1,
|
||
config.minTp1Percent,
|
||
config.maxTp1Percent
|
||
)
|
||
tp2Percent = this.calculatePercentFromAtr(
|
||
signal.originalSignalData.atr,
|
||
fillPrice,
|
||
config.atrMultiplierTp2,
|
||
config.minTp2Percent,
|
||
config.maxTp2Percent
|
||
)
|
||
slPercent = -Math.abs(this.calculatePercentFromAtr(
|
||
signal.originalSignalData.atr,
|
||
fillPrice,
|
||
config.atrMultiplierSl,
|
||
config.minSlPercent,
|
||
config.maxSlPercent
|
||
))
|
||
}
|
||
|
||
const stopLossPrice = this.calculatePrice(fillPrice, slPercent, signal.direction)
|
||
const tp1Price = this.calculatePrice(fillPrice, tp1Percent, signal.direction)
|
||
const tp2Price = this.calculatePrice(fillPrice, tp2Percent, signal.direction)
|
||
const effectiveTp2SizePercent =
|
||
config.useTp2AsTriggerOnly && (config.takeProfit2SizePercent ?? 0) <= 0
|
||
? 0
|
||
: (config.takeProfit2SizePercent ?? 0)
|
||
|
||
// Dual stops if enabled
|
||
let softStopPrice: number | undefined
|
||
let hardStopPrice: number | undefined
|
||
|
||
if (config.useDualStops) {
|
||
softStopPrice = this.calculatePrice(fillPrice, config.softStopPercent, signal.direction)
|
||
hardStopPrice = this.calculatePrice(fillPrice, config.hardStopPercent, signal.direction)
|
||
}
|
||
|
||
// Place exit orders
|
||
let exitOrderSignatures: string[] = []
|
||
try {
|
||
const exitRes = await placeExitOrders({
|
||
symbol: signal.symbol,
|
||
positionSizeUSD,
|
||
positionSizeTokens: openResult.fillSize, // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||
entryPrice: fillPrice,
|
||
tp1Price,
|
||
tp2Price,
|
||
stopLossPrice,
|
||
tp1SizePercent: config.takeProfit1SizePercent ?? 75,
|
||
tp2SizePercent: effectiveTp2SizePercent,
|
||
direction: signal.direction,
|
||
useDualStops: config.useDualStops,
|
||
softStopPrice,
|
||
softStopBuffer: config.softStopBuffer,
|
||
hardStopPrice
|
||
})
|
||
|
||
if (exitRes.success) {
|
||
exitOrderSignatures = exitRes.signatures || []
|
||
logger.log(`✅ Smart Entry: Exit orders placed - ${exitOrderSignatures.length} orders`)
|
||
}
|
||
} catch (err) {
|
||
console.error(`❌ Smart Entry: Error placing exit orders:`, err)
|
||
}
|
||
|
||
// Save to database
|
||
let savedTrade
|
||
try {
|
||
savedTrade = await createTrade({
|
||
positionId: openResult.transactionSignature,
|
||
symbol: signal.symbol,
|
||
direction: signal.direction,
|
||
entryPrice: fillPrice,
|
||
positionSizeUSD,
|
||
leverage,
|
||
stopLossPrice,
|
||
takeProfit1Price: tp1Price,
|
||
takeProfit2Price: tp2Price,
|
||
tp1SizePercent: config.takeProfit1SizePercent,
|
||
tp2SizePercent: effectiveTp2SizePercent,
|
||
entryOrderTx: openResult.transactionSignature,
|
||
atrAtEntry: signal.originalSignalData.atr,
|
||
adxAtEntry: signal.originalSignalData.adx,
|
||
rsiAtEntry: signal.originalSignalData.rsi,
|
||
signalQualityScore: signal.qualityScore,
|
||
indicatorVersion: signal.originalSignalData.indicatorVersion,
|
||
signalSource: 'tradingview',
|
||
tp1OrderTx: exitOrderSignatures[0],
|
||
tp2OrderTx: exitOrderSignatures[1],
|
||
slOrderTx: exitOrderSignatures[2],
|
||
configSnapshot: {
|
||
leverage,
|
||
stopLossPercent: slPercent,
|
||
takeProfit1Percent: tp1Percent,
|
||
takeProfit2Percent: tp2Percent,
|
||
useDualStops: config.useDualStops,
|
||
smartEntry: {
|
||
used: true,
|
||
improvement: improvementDirection,
|
||
waitTime: Math.round((signal.executedAt! - signal.signalTime) / 1000),
|
||
reason: reason,
|
||
checksPerformed: signal.checksPerformed
|
||
}
|
||
}
|
||
})
|
||
|
||
logger.log(`💾 Smart Entry: Trade saved to database (ID: ${savedTrade.id})`)
|
||
} catch (dbError) {
|
||
console.error(`❌ Smart Entry: Failed to save trade:`, dbError)
|
||
const { logCriticalError } = await import('../utils/persistent-logger')
|
||
logCriticalError('SMART_ENTRY_DATABASE_FAILURE', {
|
||
error: dbError,
|
||
symbol: signal.symbol,
|
||
direction: signal.direction,
|
||
entryPrice: fillPrice,
|
||
transactionSignature: openResult.transactionSignature
|
||
})
|
||
// If database save fails, generate synthetic ID as fallback
|
||
savedTrade = { id: `trade-${Date.now()}` } as any
|
||
}
|
||
|
||
// Add to Position Manager
|
||
try {
|
||
const positionManager = await getInitializedPositionManager()
|
||
|
||
const emergencyStopPrice = this.calculatePrice(
|
||
fillPrice,
|
||
-Math.abs(config.emergencyStopPercent),
|
||
signal.direction
|
||
)
|
||
|
||
const activeTrade: import('./position-manager').ActiveTrade = {
|
||
id: savedTrade.id, // 🔧 BUG #88 FIX: Use real Prisma ID from database
|
||
positionId: openResult.transactionSignature,
|
||
symbol: signal.symbol,
|
||
direction: signal.direction,
|
||
entryPrice: fillPrice,
|
||
entryTime: Date.now(),
|
||
positionSize: positionSizeUSD,
|
||
leverage,
|
||
stopLossPrice,
|
||
tp1Price,
|
||
tp2Price,
|
||
emergencyStopPrice,
|
||
currentSize: positionSizeUSD,
|
||
originalPositionSize: positionSizeUSD,
|
||
takeProfitPrice1: tp1Price,
|
||
takeProfitPrice2: tp2Price,
|
||
tp1Hit: false,
|
||
tp2Hit: false,
|
||
slMovedToBreakeven: false,
|
||
slMovedToProfit: false,
|
||
trailingStopActive: false,
|
||
realizedPnL: 0,
|
||
unrealizedPnL: 0,
|
||
peakPnL: 0,
|
||
peakPrice: fillPrice,
|
||
maxFavorableExcursion: 0,
|
||
maxAdverseExcursion: 0,
|
||
maxFavorablePrice: fillPrice,
|
||
maxAdversePrice: fillPrice,
|
||
originalAdx: signal.originalSignalData.adx,
|
||
timesScaled: 0,
|
||
totalScaleAdded: 0,
|
||
priceCheckCount: 0,
|
||
lastPrice: fillPrice,
|
||
lastUpdateTime: Date.now(),
|
||
atrAtEntry: signal.originalSignalData.atr,
|
||
adxAtEntry: signal.originalSignalData.adx,
|
||
signalQualityScore: signal.qualityScore
|
||
}
|
||
|
||
await positionManager.addTrade(activeTrade)
|
||
logger.log(`📊 Smart Entry: Added to Position Manager`)
|
||
|
||
// CRITICAL FIX (Dec 13, 2025): Send Telegram notification for timeout executions
|
||
// Bug: Telegram receives "null" when smart entry times out
|
||
// Fix: Send notification directly from executeSignal since it runs outside API context
|
||
try {
|
||
const { sendTelegramMessage } = await import('../notifications/telegram')
|
||
const timeWaited = Math.round((Date.now() - signal.receivedAt) / 1000)
|
||
const message = `
|
||
🎯 POSITION OPENED (Smart Entry ${reason})
|
||
|
||
📈 ${signal.symbol} ${signal.direction.toUpperCase()}
|
||
|
||
💰 Size: $${positionSizeUSD.toFixed(2)}
|
||
⚡ Leverage: ${leverage}x
|
||
🎯 Quality: ${signal.qualityScore}
|
||
|
||
📍 Entry: $${fillPrice.toFixed(4)}
|
||
🎯 TP1: $${tp1Price.toFixed(4)} (+${tp1Percent.toFixed(2)}%)
|
||
🎯 TP2: $${tp2Price.toFixed(4)} (+${tp2Percent.toFixed(2)}%)
|
||
🛡️ SL: $${stopLossPrice.toFixed(4)} (${slPercent.toFixed(2)}%)
|
||
|
||
⏱️ Wait time: ${timeWaited}s
|
||
📊 Entry improvement: ${improvementDirection >= 0 ? '+' : ''}${improvementDirection.toFixed(2)}%
|
||
💵 Value saved: $${(Math.abs(improvementDirection) / 100 * positionSizeUSD).toFixed(2)}
|
||
|
||
${reason === 'timeout' ? '⏰ Executed at timeout (max wait reached)' : '✅ Optimal entry confirmed'}
|
||
`.trim()
|
||
|
||
await sendTelegramMessage(message)
|
||
logger.log(`📱 Smart Entry: Telegram notification sent`)
|
||
} catch (telegramError) {
|
||
console.error(`❌ Smart Entry: Telegram notification failed:`, telegramError)
|
||
// Don't fail the trade execution just because notification failed
|
||
}
|
||
} catch (pmError) {
|
||
console.error(`❌ Smart Entry: Failed to add to Position Manager:`, pmError)
|
||
}
|
||
|
||
logger.log(`✅ Smart Entry: Execution complete for ${signal.symbol}`)
|
||
logger.log(` Entry improvement: ${improvementDirection >= 0 ? '+' : ''}${improvementDirection.toFixed(2)}%`)
|
||
logger.log(` Estimated value: $${(Math.abs(improvementDirection) / 100 * positionSizeUSD).toFixed(2)}`)
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Smart Entry: Execution error:`, error)
|
||
}
|
||
|
||
// Remove from queue after brief delay (for logging)
|
||
setTimeout(() => {
|
||
this.queuedSignals.delete(signal.id)
|
||
logger.log(`🗑️ Smart Entry: Cleaned up signal ${signal.id}`)
|
||
|
||
if (this.queuedSignals.size === 0) {
|
||
this.stopMonitoring()
|
||
}
|
||
}, 5000)
|
||
}
|
||
|
||
/**
|
||
* Helper: Calculate price from percentage
|
||
*/
|
||
private calculatePrice(
|
||
entryPrice: number,
|
||
percent: number,
|
||
direction: 'long' | 'short'
|
||
): number {
|
||
if (direction === 'long') {
|
||
return entryPrice * (1 + percent / 100)
|
||
} else {
|
||
return entryPrice * (1 - percent / 100)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper: Calculate percentage from ATR with safety bounds
|
||
*/
|
||
private calculatePercentFromAtr(
|
||
atrValue: number,
|
||
entryPrice: number,
|
||
atrMultiplier: number,
|
||
minPercent: number,
|
||
maxPercent: number
|
||
): number {
|
||
const atrPercent = (atrValue / entryPrice) * 100
|
||
const targetPercent = atrPercent * atrMultiplier
|
||
return Math.max(minPercent, Math.min(maxPercent, targetPercent))
|
||
}
|
||
|
||
/**
|
||
* Get status of all queued signals (for debugging)
|
||
*/
|
||
getQueueStatus(): QueuedSignal[] {
|
||
return Array.from(this.queuedSignals.values())
|
||
}
|
||
|
||
/**
|
||
* Cancel a specific queued signal
|
||
*/
|
||
cancelSignal(signalId: string): boolean {
|
||
const signal = this.queuedSignals.get(signalId)
|
||
if (!signal) {
|
||
return false
|
||
}
|
||
|
||
signal.status = 'cancelled'
|
||
this.queuedSignals.delete(signalId)
|
||
logger.log(`🚫 Smart Entry: Cancelled signal ${signalId}`)
|
||
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Check if smart entry is enabled
|
||
*/
|
||
isEnabled(): boolean {
|
||
return this.config.enabled
|
||
}
|
||
}
|
||
|
||
// Singleton instance
|
||
let smartEntryTimerInstance: SmartEntryTimer | null = null
|
||
|
||
export function getSmartEntryTimer(): SmartEntryTimer {
|
||
if (!smartEntryTimerInstance) {
|
||
smartEntryTimerInstance = new SmartEntryTimer()
|
||
}
|
||
return smartEntryTimerInstance
|
||
}
|
||
|
||
export function startSmartEntryTracking(): void {
|
||
getSmartEntryTimer()
|
||
logger.log('✅ Smart Entry Timer service initialized')
|
||
}
|