/**
* Bounce Detection Analysis
* checkBounceSetup, checkBouncePersistence, checkEMAReclaim, checkReentryBounce, monitorBounceExit, calculateBounceDecay functions
*/
/**
* Check Bounce Setup
* Detects when price exits no-trade zone (exhaustion) and shows reversal potential
* Returns bounce type and strength if bounce setup detected
*/
export function checkBounceSetup(historicalData, indicators, price) {
if (!historicalData || historicalData.length < 2 || !indicators || !price) {
return null;
}
const currentCandle = historicalData[historicalData.length - 1];
const previousCandle = historicalData[historicalData.length - 2];
if (!currentCandle || !previousCandle) {
return null;
}
const bbLower = indicators.bollingerBands?.lower;
const bbUpper = indicators.bollingerBands?.upper;
const rsi = indicators.rsi14 || indicators.rsi7;
const stochK = indicators.stochastic?.k;
const stochD = indicators.stochastic?.d;
const atr = indicators.atr;
const volume = currentCandle.volume || 0;
// Calculate average volume (last 10 candles)
let avgVolume = 0;
if (historicalData.length >= 10) {
const recentVolumes = historicalData.slice(-10).map(c => c.volume || 0);
avgVolume = recentVolumes.reduce((a, b) => a + b, 0) / recentVolumes.length;
}
// Calculate candle body size
const candleBody = Math.abs(currentCandle.close - currentCandle.open);
// Bullish bounce: Price was below BB Lower, now closed above it
if (bbLower && previousCandle.close < bbLower && currentCandle.close > bbLower) {
// Check confirmations
const rsiOversold = rsi !== null && rsi !== undefined && rsi < 40;
const stochKValue = stochK !== null && stochK !== undefined ? stochK : 0;
const stochDValue = stochD !== null && stochD !== undefined ? stochD : 0;
const stochBullish = stochKValue > stochDValue && stochKValue > 0 && stochDValue > 0;
const volumeConfirmed = avgVolume > 0 && volume > avgVolume;
const atrValid = atr !== null && atr !== undefined && atr > 0 && price > 0 && (atr / price) * 100 > 1.5;
const candleBodyValid = atr !== null && atr !== undefined && atr > 0 && candleBody > (atr * 0.5);
// Count confirmations
let confirmations = 0;
if (rsiOversold)
confirmations++;
if (stochBullish)
confirmations++;
if (volumeConfirmed)
confirmations++;
if (atrValid)
confirmations++;
if (candleBodyValid)
confirmations++;
// Minimum 2 confirmations required
if (confirmations >= 2) {
const bounceStrength = confirmations / 5; // 0.2 to 1.0
const stochKValueStr = stochKValue > 0 ? stochKValue.toFixed(1) : 'N/A';
const stochDValueStr = stochDValue > 0 ? stochDValue.toFixed(1) : 'N/A';
const rsiValue = rsi !== null && rsi !== undefined ? rsi.toFixed(1) : 'N/A';
return {
type: 'BUY_BOUNCE',
strength: bounceStrength,
confirmations: confirmations,
reason: `Rebound from BB Lower + ${confirmations} confirmations (RSI: ${rsiValue}, Stoch: ${stochKValueStr}/${stochDValueStr})`,
rsiOversold,
stochBullish,
volumeConfirmed,
atrValid,
candleBodyValid
};
}
}
// Bearish bounce: Price was above BB Upper, now closed below it
if (bbUpper && previousCandle.close > bbUpper && currentCandle.close < bbUpper) {
// Check confirmations
const rsiOverbought = rsi !== null && rsi !== undefined && rsi > 60;
const stochKValue = stochK !== null && stochK !== undefined ? stochK : 0;
const stochDValue = stochD !== null && stochD !== undefined ? stochD : 0;
const stochBearish = stochKValue < stochDValue && stochKValue > 0 && stochDValue > 0;
const volumeConfirmed = avgVolume > 0 && volume > avgVolume;
const atrValid = atr !== null && atr !== undefined && atr > 0 && price > 0 && (atr / price) * 100 > 1.5;
const candleBodyValid = atr !== null && atr !== undefined && atr > 0 && candleBody > (atr * 0.5);
// Count confirmations
let confirmations = 0;
if (rsiOverbought)
confirmations++;
if (stochBearish)
confirmations++;
if (volumeConfirmed)
confirmations++;
if (atrValid)
confirmations++;
if (candleBodyValid)
confirmations++;
// Minimum 2 confirmations required
if (confirmations >= 2) {
const bounceStrength = confirmations / 5; // 0.2 to 1.0
const stochKValueStr = stochKValue > 0 ? stochKValue.toFixed(1) : 'N/A';
const stochDValueStr = stochDValue > 0 ? stochDValue.toFixed(1) : 'N/A';
const rsiValue = rsi !== null && rsi !== undefined ? rsi.toFixed(1) : 'N/A';
return {
type: 'SELL_BOUNCE',
strength: bounceStrength,
confirmations: confirmations,
reason: `Pullback from BB Upper + ${confirmations} confirmations (RSI: ${rsiValue}, Stoch: ${stochKValueStr}/${stochDValueStr})`,
rsiOverbought,
stochBearish: stochBearish,
volumeConfirmed,
atrValid,
candleBodyValid
};
}
}
return null;
}
/**
* Check Bounce Persistence
* Detects if bounce is continuing or failing (dead cat bounce)
*/
export function checkBouncePersistence(historicalData, signal, currentPrice) {
if (!historicalData || historicalData.length < 3 || !currentPrice) {
return { persistent: true, confidencePenalty: 0, reason: 'Insufficient data' };
}
const isBuyBounce = signal.bounce_type === 'BUY_BOUNCE' ||
(signal.signal === 'buy_to_enter' && signal.bounce_mode);
const isSellBounce = signal.bounce_type === 'SELL_BOUNCE' ||
(signal.signal === 'sell_to_enter' && signal.bounce_mode);
if (!isBuyBounce && !isSellBounce) {
return { persistent: true, confidencePenalty: 0, reason: 'Not a bounce signal' };
}
// Check last 3 candles
const recentCandles = historicalData.slice(-3);
if (recentCandles.length < 3) {
return { persistent: true, confidencePenalty: 0, reason: 'Insufficient candles' };
}
const startPrice = recentCandles[0].close;
const endPrice = currentPrice;
// CRITICAL FIX: Check for division by zero - startPrice can be 0
if (!startPrice || startPrice <= 0) {
return { persistent: true, confidencePenalty: 0, reason: 'Invalid start price (zero or negative)' };
}
const priceChange = ((endPrice - startPrice) / startPrice) * 100;
// For BUY bounce: need price to increase by at least 0.5%
if (isBuyBounce) {
if (priceChange < 0.5) {
// Bounce failed - price didn't increase enough
return {
persistent: false,
confidencePenalty: 0.50, // Cut confidence 50%
reason: `Price failed to increase 0.5% in 3 candles (only ${priceChange.toFixed(2)}%) - dead cat bounce detected`,
priceChange
};
}
}
// For SELL bounce: need price to decrease by at least 0.5%
if (isSellBounce) {
if (priceChange > -0.5) {
// Bounce failed - price didn't decrease enough
return {
persistent: false,
confidencePenalty: 0.50, // Cut confidence 50%
reason: `Price failed to decrease 0.5% in 3 candles (only ${priceChange.toFixed(2)}%) - failed pullback detected`,
priceChange
};
}
}
// Bounce is persistent
return {
persistent: true,
confidencePenalty: 0,
reason: `Bounce persistent: ${isBuyBounce ? 'price increased' : 'price decreased'} ${Math.abs(priceChange).toFixed(2)}% in 3 candles`,
priceChange
};
}
/**
* Check EMA Reclaim
* Bounce BUY valid kalau harga reclaim EMA20 (atau 4H EMA8 untuk intraday)
* Bounce SELL valid kalau gagal reclaim EMA20
*/
export function checkEMAReclaim(signal, indicators, multiTimeframeIndicators, currentPrice) {
if (!indicators || !currentPrice) {
return { valid: true, reason: 'Insufficient data' };
}
const isBuyBounce = signal.bounce_type === 'BUY_BOUNCE' ||
(signal.signal === 'buy_to_enter' && signal.bounce_mode);
const isSellBounce = signal.bounce_type === 'SELL_BOUNCE' ||
(signal.signal === 'sell_to_enter' && signal.bounce_mode);
if (!isBuyBounce && !isSellBounce) {
return { valid: true, reason: 'Not a bounce signal' };
}
// Check EMA20 reclaim for 1H timeframe
const ema20 = indicators.ema20;
// Check 4H EMA8 if available (for intraday)
let ema4h8 = null;
if (multiTimeframeIndicators && multiTimeframeIndicators['4h']) {
const ema4h = multiTimeframeIndicators['4h']?.ema;
if (ema4h && ema4h.length >= 8) {
// Calculate EMA8 from 4H data
ema4h8 = ema4h[ema4h.length - 1]; // Last EMA value
}
}
// For BUY bounce: price should reclaim EMA20 (or 4H EMA8)
if (isBuyBounce) {
if (ema20 !== null && ema20 !== undefined && currentPrice > ema20) {
return {
valid: true,
reason: `Price reclaimed EMA20 ($${currentPrice.toFixed(2)} > $${ema20.toFixed(2)}) - bounce continuation confirmed`,
emaReclaimed: true,
emaLevel: ema20
};
}
else if (ema4h8 !== null && currentPrice > ema4h8) {
return {
valid: true,
reason: `Price reclaimed 4H EMA8 ($${currentPrice.toFixed(2)} > $${ema4h8.toFixed(2)}) - bounce continuation confirmed`,
emaReclaimed: true,
emaLevel: ema4h8,
timeframe: '4h'
};
}
else {
// Price failed to reclaim EMA - dead cat bounce
return {
valid: false,
reason: `Price failed to reclaim EMA20 ($${currentPrice.toFixed(2)} < $${ema20?.toFixed(2) || 'N/A'}) - dead cat bounce risk`,
emaReclaimed: false,
emaLevel: ema20 || undefined
};
}
}
// For SELL bounce: price should fail to reclaim EMA20
if (isSellBounce) {
if (ema20 !== null && ema20 !== undefined && currentPrice < ema20) {
return {
valid: true,
reason: `Price failed to reclaim EMA20 ($${currentPrice.toFixed(2)} < $${ema20.toFixed(2)}) - pullback continuation confirmed`,
emaReclaimed: false,
emaLevel: ema20
};
}
else if (ema20 !== null && ema20 !== undefined && currentPrice > ema20) {
// Price reclaimed EMA - bounce failed
return {
valid: false,
reason: `Price reclaimed EMA20 ($${currentPrice.toFixed(2)} > $${ema20.toFixed(2)}) - pullback failed, potential reversal`,
emaReclaimed: true,
emaLevel: ema20
};
}
}
return { valid: true, reason: 'EMA data not available' };
}
/**
* Monitor Bounce Exit
* Detects when bounce is weakening (e.g., price closes below EMA8 after rising >3%)
* Returns exit signal for 50% position trim
*/
export function monitorBounceExit(signal, historicalData, indicators, entryPrice, currentPrice) {
if (!signal.bounce_mode || !historicalData || historicalData.length < 3 || !indicators || !entryPrice || !currentPrice) {
return { shouldTrim: false, reason: 'Not a bounce signal or insufficient data' };
}
const isBuyBounce = signal.bounce_type === 'BUY_BOUNCE' ||
(signal.signal === 'buy_to_enter' && signal.bounce_mode);
const isSellBounce = signal.bounce_type === 'SELL_BOUNCE' ||
(signal.signal === 'sell_to_enter' && signal.bounce_mode);
if (!isBuyBounce && !isSellBounce) {
return { shouldTrim: false, reason: 'Not a bounce signal' };
}
const ema8 = indicators.ema8;
if (!ema8 || ema8 === null) {
return { shouldTrim: false, reason: 'EMA8 not available' };
}
const priceChange = ((currentPrice - entryPrice) / entryPrice) * 100;
// For BUY bounce: if price rose >3% then closed below EMA8, trim 50%
if (isBuyBounce) {
if (priceChange > 3.0 && currentPrice < ema8) {
return {
shouldTrim: true,
reason: `Bounce weakening: price rose ${priceChange.toFixed(2)}% but closed below EMA8 - trim 50%`,
trimPercentage: 50
};
}
}
// For SELL bounce: if price fell >3% then closed above EMA8, trim 50%
if (isSellBounce) {
if (priceChange < -3.0 && currentPrice > ema8) {
return {
shouldTrim: true,
reason: `Pullback weakening: price fell ${Math.abs(priceChange).toFixed(2)}% but closed above EMA8 - trim 50%`,
trimPercentage: 50
};
}
}
return { shouldTrim: false, reason: 'Bounce still valid, no trim needed' };
}
/**
* Calculate Bounce Decay
* Measures how much bounce strength has decayed over time
*/
export function calculateBounceDecay(signal, historicalData, _timeframe = '1h') {
if (!signal.bounce_mode || !historicalData || historicalData.length < 5) {
return { decayPercent: 0, isDecaying: false, reason: 'Insufficient data' };
}
const isBuyBounce = signal.bounce_type === 'BUY_BOUNCE' ||
(signal.signal === 'buy_to_enter' && signal.bounce_mode);
const isSellBounce = signal.bounce_type === 'SELL_BOUNCE' ||
(signal.signal === 'sell_to_enter' && signal.bounce_mode);
if (!isBuyBounce && !isSellBounce) {
return { decayPercent: 0, isDecaying: false, reason: 'Not a bounce signal' };
}
// Compare last 5 candles to previous 5 candles
const recentCandles = historicalData.slice(-5);
const previousCandles = historicalData.length >= 10 ? historicalData.slice(-10, -5) : [];
if (recentCandles.length < 5 || previousCandles.length < 5) {
return { decayPercent: 0, isDecaying: false, reason: 'Insufficient candles' };
}
// CRITICAL FIX: Check for division by zero before calculating price changes
const recentStartPrice = recentCandles[0].close;
const previousStartPrice = previousCandles[0].close;
if (!recentStartPrice || recentStartPrice <= 0 || !previousStartPrice || previousStartPrice <= 0) {
return { decayPercent: 0, isDecaying: false, reason: 'Invalid price data (zero or negative)' };
}
const recentChange = ((recentCandles[recentCandles.length - 1].close - recentStartPrice) / recentStartPrice) * 100;
const previousChange = ((previousCandles[previousCandles.length - 1].close - previousStartPrice) / previousStartPrice) * 100;
// Calculate decay: if recent change is slower than previous, bounce is decaying
let decayPercent = 0;
if (isBuyBounce) {
// For BUY: if recent upward move is slower than previous, decay
if (recentChange < previousChange && recentChange > 0 && Math.abs(previousChange) > 0) {
decayPercent = ((previousChange - recentChange) / previousChange) * 100;
}
}
else if (isSellBounce) {
// For SELL: if recent downward move is slower than previous, decay
if (recentChange > previousChange && recentChange < 0 && Math.abs(previousChange) > 0) {
decayPercent = ((Math.abs(previousChange) - Math.abs(recentChange)) / Math.abs(previousChange)) * 100;
}
}
const isDecaying = decayPercent > 10; // Decaying if >10% slower
return {
decayPercent,
isDecaying,
reason: isDecaying
? `Bounce decay detected: recent move (${recentChange.toFixed(2)}%) is ${decayPercent.toFixed(1)}% slower than previous (${previousChange.toFixed(2)}%)`
: `Bounce still strong: recent move (${recentChange.toFixed(2)}%) is similar to previous (${previousChange.toFixed(2)}%)`
};
}
/**
* Check Reentry Bounce
* Detects if price is showing signs of a new bounce setup after initial exit
*/
export function checkReentryBounce(signal, historicalData, indicators, multiTimeframeIndicators, currentPrice) {
if (!signal.bounce_mode || !historicalData || historicalData.length < 3 || !indicators || !currentPrice) {
return { shouldReenter: false, reason: 'Not a bounce signal or insufficient data' };
}
// Check if new bounce setup is detected
const bounceSetup = checkBounceSetup(historicalData, indicators, currentPrice);
if (!bounceSetup) {
return { shouldReenter: false, reason: 'No new bounce setup detected' };
}
// Check EMA reclaim for additional confirmation
const emaReclaim = checkEMAReclaim(signal, indicators, multiTimeframeIndicators, currentPrice);
if (emaReclaim.valid && bounceSetup.strength > 0.4) {
return {
shouldReenter: true,
reason: `New bounce setup detected with ${bounceSetup.confirmations} confirmations and EMA reclaim - reentry signal`,
reentryStrength: bounceSetup.strength
};
}
return {
shouldReenter: false,
reason: `Bounce setup detected but strength (${bounceSetup.strength.toFixed(2)}) or EMA reclaim insufficient for reentry`
};
}