Smart entry timer was creating synthetic trade IDs (trade-${Date.now()})
instead of using real database IDs from createTrade() return value.
This caused SL verification to fail with Prisma error 'No record found
for update' when attempting to:
- Save SL recovery signatures (attemptSLPlacement)
- Mark trades as emergency closed (haltTradingAndClosePosition)
Fix:
- Capture createTrade() return value as savedTrade
- Use savedTrade.id for ActiveTrade object
- Add fallback for database save failures
- Log real database ID for verification
Impact: SL verification can now update smart entry trades successfully.
Both active recovery and emergency shutdown will work correctly.
Related: Bug #87 Phase 2 (active SL recovery)
780 lines
28 KiB
TypeScript
780 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,
|
|
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')
|
|
}
|