Files
trading_bot_v4/lib/trading/smart-entry-timer.ts
mindesbunister 361f3ba183 critical: Fix exit order token sizing - TP/SL now use exact position size
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
2026-01-07 09:59:36 +01:00

781 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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')
}