β ΠΠ°Π·Π°Π΄/**
* Liquidity Sweep + Pin Bar Signal Detector
*
* Step 1: Swing level detection + round number levels
* - findSwingLevels() β fractal-based swing high/low from klines
* - findRoundNumbers() β psychological round-number levels near price
*/
// ======================== SWING HIGH/LOW DETECTION ========================
/**
* Find swing highs and lows using fractal method.
* A swing high: candle.high > N candles on each side.
* A swing low: candle.low < N candles on each side.
*
* @param {Array} candles β [{time, open, high, low, close, volume}, ...] ASC order
* @param {number} leftBars β candles to the left for confirmation (default 3)
* @param {number} rightBars β candles to the right for confirmation (default 3)
* @returns {Array} [{ price, type: 'swing_high'|'swing_low', time, strength, touches }]
*/
function findSwingLevels(candles, leftBars = 3, rightBars = 3) {
if (!Array.isArray(candles) || candles.length < leftBars + rightBars + 1) return []
const raws = [] // raw swing points before clustering
for (let i = leftBars; i < candles.length - rightBars; i++) {
const c = candles[i]
// --- Swing High ---
let isSwingHigh = true
for (let l = 1; l <= leftBars; l++) {
if (candles[i - l].high >= c.high) { isSwingHigh = false; break }
}
if (isSwingHigh) {
for (let r = 1; r <= rightBars; r++) {
if (candles[i + r].high >= c.high) { isSwingHigh = false; break }
}
}
if (isSwingHigh) {
raws.push({ price: c.high, type: 'swing_high', time: c.time, volume: c.volume })
}
// --- Swing Low ---
let isSwingLow = true
for (let l = 1; l <= leftBars; l++) {
if (candles[i - l].low <= c.low) { isSwingLow = false; break }
}
if (isSwingLow) {
for (let r = 1; r <= rightBars; r++) {
if (candles[i + r].low <= c.low) { isSwingLow = false; break }
}
}
if (isSwingLow) {
raws.push({ price: c.low, type: 'swing_low', time: c.time, volume: c.volume })
}
}
// --- Cluster nearby levels (within 0.15% of each other) ---
// Keeps the strongest (most touches) and freshest level per cluster
const CLUSTER_PCT = 0.0015
const clustered = clusterLevels(raws, CLUSTER_PCT)
return clustered
}
/**
* Cluster raw swing points that are within pctThreshold of each other.
* Returns deduplicated levels with touch count and strength score.
*/
function clusterLevels(raws, pctThreshold) {
if (raws.length === 0) return []
// Sort by price
const sorted = [...raws].sort((a, b) => a.price - b.price)
const clusters = []
let current = [sorted[0]]
for (let i = 1; i < sorted.length; i++) {
const prev = current[current.length - 1]
const pctDiff = Math.abs(sorted[i].price - prev.price) / prev.price
if (pctDiff <= pctThreshold && sorted[i].type === prev.type) {
current.push(sorted[i])
} else {
clusters.push(current)
current = [sorted[i]]
}
}
clusters.push(current)
// Reduce each cluster to a single level
return clusters.map(group => {
const touches = group.length
// Use the price with the most recent touch (freshest)
const freshest = group.reduce((a, b) => a.time > b.time ? a : b)
// Strength: more touches = stronger, capped at 10
const strength = Math.min(10, touches * 2 + 1)
return {
price: freshest.price,
type: freshest.type,
time: freshest.time,
touches,
strength,
source: 'swing',
}
})
}
// ======================== ORDER BOOK WALL LEVELS ========================
/**
* Extract significant wall levels from the live order book.
* Uses stateManager for raw levels and densityV2 for statistical detection.
*
* @param {Object} params
* @param {string} params.symbol
* @param {number} params.markPrice
* @param {Object} params.stateManager β server/state.js singleton
* @param {Object} params.densityV2 β server/densityV2.js module
* @param {Map} params.persistenceMap β densityV2 persistence store (from index.js)
* @param {number} params.windowPct β price window % (default 3)
* @returns {Array} [{ price, type: 'bid_wall'|'ask_wall', strength, notional, score, status, source }]
*/
function getWallLevels({ symbol, markPrice, stateManager, densityV2, persistenceMap, windowPct = 3 }) {
if (!stateManager || !markPrice) return []
// Check if we have WS data for this symbol
if (!stateManager.books || !stateManager.books.has(symbol)) return []
const book = stateManager.books.get(symbol)
// Gather raw levels from the order book within the window
const minPrice = markPrice * (1 - windowPct / 100)
const maxPrice = markPrice * (1 + windowPct / 100)
const bidLevels = []
for (const [price, data] of book.bids.entries()) {
if (price >= minPrice && price <= maxPrice) {
bidLevels.push({ price, notional: data.notional, firstSeen: data.firstSeen, lastUpdate: data.lastUpdate })
}
}
const askLevels = []
for (const [price, data] of book.asks.entries()) {
if (price >= minPrice && price <= maxPrice) {
askLevels.push({ price, notional: data.notional, firstSeen: data.firstSeen, lastUpdate: data.lastUpdate })
}
}
// If densityV2 and persistenceMap are available, use statistical wall detection
if (densityV2 && persistenceMap) {
try {
const analysis = densityV2.analyzeSymbol({
symbol, markPrice, bidLevels, askLevels, persistenceMap, windowPct, nSigma: 2.0
})
const walls = []
// Convert bidWalls (support) to liquidity levels
if (Array.isArray(analysis.bidWalls)) {
for (const w of analysis.bidWalls) {
walls.push({
price: w.price,
type: 'bid_wall',
strength: Math.min(10, Math.round(w.score || 1)),
notional: w.notional,
score: w.score,
status: w.status,
distancePct: w.distancePct,
source: 'wall',
time: null,
touches: 0,
})
}
}
// Convert askWalls (resistance) to liquidity levels
if (Array.isArray(analysis.askWalls)) {
for (const w of analysis.askWalls) {
walls.push({
price: w.price,
type: 'ask_wall',
strength: Math.min(10, Math.round(w.score || 1)),
notional: w.notional,
score: w.score,
status: w.status,
distancePct: w.distancePct,
source: 'wall',
time: null,
touches: 0,
})
}
}
return walls
} catch (e) {
// Fallback: skip densityV2, return empty
console.error('[LiqSweep] densityV2 error for', symbol, e.message)
}
}
// Fallback without densityV2: return top raw levels by notional
// (simple threshold: top 5 per side, min $50K notional)
const MIN_NOTIONAL = 50_000
const result = []
const topBids = bidLevels.filter(l => l.notional >= MIN_NOTIONAL)
.sort((a, b) => b.notional - a.notional).slice(0, 5)
for (const l of topBids) {
result.push({
price: l.price, type: 'bid_wall', strength: 3,
notional: l.notional, score: null, status: null,
distancePct: Math.abs(l.price - markPrice) / markPrice * 100,
source: 'wall', time: null, touches: 0,
})
}
const topAsks = askLevels.filter(l => l.notional >= MIN_NOTIONAL)
.sort((a, b) => b.notional - a.notional).slice(0, 5)
for (const l of topAsks) {
result.push({
price: l.price, type: 'ask_wall', strength: 3,
notional: l.notional, score: null, status: null,
distancePct: Math.abs(l.price - markPrice) / markPrice * 100,
source: 'wall', time: null, touches: 0,
})
}
return result
}
// ======================== ROUND NUMBER LEVELS ========================
/**
* Generate psychological round-number levels near the current price.
* Adapts step size to the price magnitude.
*
* @param {number} price β current mark price
* @param {number} windowPct β how far above/below to look (default 2%)
* @returns {Array} [{ price, type: 'round_number', strength, source }]
*/
function findRoundNumbers(price, windowPct = 2) {
if (!price || price <= 0) return []
// Determine step based on price magnitude
// BTC ~60000 β step 1000, ETH ~3000 β step 100, altcoin ~1.5 β step 0.1
const step = getRoundStep(price)
const halfStep = step / 2 // sub-levels (e.g. 500 for BTC)
const windowAbs = price * (windowPct / 100)
const lo = price - windowAbs
const hi = price + windowAbs
const levels = []
// Start from the nearest round number below lo
const start = Math.floor(lo / halfStep) * halfStep
for (let p = start; p <= hi; p += halfStep) {
if (p <= 0) continue
if (p < lo) continue
// Full round (e.g. 60000) is stronger than half (e.g. 60500)
const isFull = Math.abs(p % step) < step * 0.001
const strength = isFull ? 4 : 2
levels.push({
price: p,
type: 'round_number',
strength,
source: 'round',
time: null,
touches: 0,
})
}
return levels
}
/**
* Determine the appropriate round-number step for a given price.
*/
function getRoundStep(price) {
if (price >= 10000) return 1000 // BTC: 60000, 61000, ...
if (price >= 1000) return 100 // ETH: 3000, 3100, ...
if (price >= 100) return 10 // SOL: 150, 160, ...
if (price >= 10) return 1 // LINK: 14, 15, ...
if (price >= 1) return 0.1 // DOGE: 0.3, 0.4, ...
if (price >= 0.01) return 0.01 // SHIB-like: 0.01, 0.02, ...
return 0.001
}
// ======================== PIN BAR DETECTION ========================
/**
* Detect whether a candle is a pin bar (rejection candle).
*
* Bullish pin bar (LONG signal):
* - Long lower wick (β₯ wickMinRatio of total range)
* - Small body (β€ bodyMaxRatio of total range)
* - Close in the upper portion of the range
*
* Bearish pin bar (SHORT signal):
* - Long upper wick (β₯ wickMinRatio of total range)
* - Small body (β€ bodyMaxRatio of total range)
* - Close in the lower portion of the range
*
* Also filters out tiny-range noise candles by requiring the range
* to exceed a minimum relative to the average range of previous candles.
*
* @param {Object} candle β { open, high, low, close, volume, time }
* @param {Array} prevCandles β preceding candles for avg range calc (5-20 recommended)
* @param {Object} opts
* @param {number} opts.wickMinRatio β min wick / range (default 0.60)
* @param {number} opts.bodyMaxRatio β max body / range (default 0.33)
* @param {number} opts.minRangeMult β candle range must be β₯ this Γ avgRange (default 0.8)
* @returns {null | { direction: 'LONG'|'SHORT', wickRatio, bodyRatio, range }}
*/
function detectPinBar(candle, prevCandles = [], opts = {}) {
if (!candle) return null
const {
wickMinRatio = 0.60,
bodyMaxRatio = 0.33,
minRangeMult = 0.8,
} = opts
const { open, high, low, close } = candle
const range = high - low
if (range <= 0) return null
// --- Filter out tiny candles (noise) ---
if (prevCandles.length >= 3) {
const avgRange = prevCandles.reduce((s, c) => s + (c.high - c.low), 0) / prevCandles.length
if (avgRange > 0 && range < avgRange * minRangeMult) return null
}
const body = Math.abs(close - open)
const bodyRatio = body / range
// Body must be small
if (bodyRatio > bodyMaxRatio) return null
const upperWick = high - Math.max(open, close)
const lowerWick = Math.min(open, close) - low
const upperWickRatio = upperWick / range
const lowerWickRatio = lowerWick / range
// --- Bullish pin bar: long lower wick, close near the top ---
if (lowerWickRatio >= wickMinRatio) {
return {
direction: 'LONG',
wickRatio: parseFloat(lowerWickRatio.toFixed(3)),
bodyRatio: parseFloat(bodyRatio.toFixed(3)),
range,
}
}
// --- Bearish pin bar: long upper wick, close near the bottom ---
if (upperWickRatio >= wickMinRatio) {
return {
direction: 'SHORT',
wickRatio: parseFloat(upperWickRatio.toFixed(3)),
bodyRatio: parseFloat(bodyRatio.toFixed(3)),
range,
}
}
return null
}
// ======================== SWEEP CONFIRMATION ========================
/**
* Check if a pin bar's wick actually swept (pierced) a liquidity level.
*
* For a bullish pin bar (LONG): the candle LOW must be BELOW a level,
* and the CLOSE must be ABOVE that level (wick grabbed liquidity, price recovered).
*
* For a bearish pin bar (SHORT): the candle HIGH must be ABOVE a level,
* and the CLOSE must be BELOW that level.
*
* If multiple levels were swept, returns the one with the highest strength.
*
* @param {Object} candle β { open, high, low, close }
* @param {Object} pinBar β from detectPinBar(): { direction }
* @param {Array} levels β [{ price, type, strength, source, ... }]
* @param {Object} opts
* @param {number} opts.maxPenetrationPct β max wick penetration past level in % of price (default 1.5)
* @returns {null | { sweptLevel, levelType, levelSource, strength, sweepDepthPct, levelsSwept }}
*/
function confirmSweep(candle, pinBar, levels, opts = {}) {
if (!candle || !pinBar || !Array.isArray(levels) || levels.length === 0) return null
const { maxPenetrationPct = 1.5 } = opts
const swept = []
if (pinBar.direction === 'LONG') {
// Wick went DOWN through a level, close recovered above it
for (const lv of levels) {
// Level should be below close (support zone) β sweep means low < level < close
if (candle.low < lv.price && candle.close > lv.price) {
const penetration = lv.price - candle.low
const penetrationPct = (penetration / lv.price) * 100
// Don't count absurdly deep sweeps β probably a dump, not a sweep
if (penetrationPct <= maxPenetrationPct) {
swept.push({
sweptLevel: lv.price,
levelType: lv.type,
levelSource: lv.source,
strength: lv.strength,
sweepDepthPct: parseFloat(penetrationPct.toFixed(3)),
notional: lv.notional || null,
})
}
}
}
} else if (pinBar.direction === 'SHORT') {
// Wick went UP through a level, close recovered below it
for (const lv of levels) {
// Level should be above close (resistance zone) β sweep means close < level < high
if (candle.high > lv.price && candle.close < lv.price) {
const penetration = candle.high - lv.price
const penetrationPct = (penetration / lv.price) * 100
if (penetrationPct <= maxPenetrationPct) {
swept.push({
sweptLevel: lv.price,
levelType: lv.type,
levelSource: lv.source,
strength: lv.strength,
sweepDepthPct: parseFloat(penetrationPct.toFixed(3)),
notional: lv.notional || null,
})
}
}
}
}
if (swept.length === 0) return null
// Pick the strongest swept level
swept.sort((a, b) => b.strength - a.strength)
const best = swept[0]
best.levelsSwept = swept.length // how many levels were taken out at once
return best
}
// ======================== CONFIDENCE SCORING ========================
/**
* Calculate confidence score (30β95) for a confirmed sweep + pin bar signal.
*
* Components:
* Base β 40 pts (a confirmed sweep+pinbar is already meaningful)
* Wick quality β 0-15 pts (how clean the pin bar is)
* Level strengthβ 0-15 pts (swing touches, wall score, confluence)
* Volume spike β 0-12 pts (candle volume vs average)
* OI drop β 0-8 pts (open interest decreased = liquidations)
* Wall absorbed β 0-5 pts (density wall disappeared after sweep)
*
* @param {Object} params
* @param {number} params.wickRatio β from detectPinBar (0.6β1.0)
* @param {number} params.levelStrength β from the swept level (1β10)
* @param {number} params.levelsSwept β how many levels taken at once (1+)
* @param {string} params.levelSource β 'swing' | 'wall' | 'round'
* @param {number} [params.volumeRatio] β candle volume / SMA volume (null if unknown)
* @param {number} [params.oiChangePct] β OI change % on that candle (negative = drop)
* @param {boolean}[params.wallAbsorbed] β density wall existed before and gone after
* @returns {number} confidence 30β95
*/
function scoreConfidence({
wickRatio = 0.6,
levelStrength = 1,
levelsSwept = 1,
levelSource = 'swing',
volumeRatio = null,
oiChangePct = null,
wallAbsorbed = false,
trendContext = null, // 'counter' | 'with' | null
fundingContext = null, // 'extreme' | null
}) {
let score = 35 // base (lowered from 40 to make room for trend+funding)
// --- Wick quality (0β15) ---
// 0.60 β 0, 0.70 β 5, 0.80 β 10, 0.90+ β 15
score += Math.min(15, Math.max(0, (wickRatio - 0.60) / 0.30 * 15))
// --- Level strength (0β15) ---
// strength 1β1.5, 5β7.5, 10β15 (wall confluence already adds +3 to strength)
let lvlPts = Math.min(10, levelStrength * 1.5)
// All signals are swing-based now (+3 base)
lvlPts += 3
// Confluence bonus: multiple levels swept at once
if (levelsSwept >= 2) lvlPts += 2
score += Math.min(15, lvlPts)
// --- Volume spike (0β12) ---
if (volumeRatio != null && volumeRatio > 1) {
// 1.5x β 3, 2x β 6, 3x β 9, 5x+ β 12
score += Math.min(12, Math.max(0, (volumeRatio - 1) * 3))
}
// --- OI drop (0β8) ---
// Negative oiChangePct means OI decreased = stops hit / liquidations
if (oiChangePct != null && oiChangePct < 0) {
const drop = Math.abs(oiChangePct)
// 0.5% β 2, 1% β 4, 2%+ β 8
score += Math.min(8, drop * 4)
}
// --- Trend context (0β10) ---
// Counter-trend sweep = exhaustion/reversal, much stronger signal
// With-trend sweep = less reliable (might just be a pullback)
if (trendContext === 'counter') {
score += 10
} else if (trendContext === 'with') {
score += 2
}
// --- Funding extreme (0β5) ---
// Sweep against overcrowded side = smart money liquidating the crowd
if (fundingContext === 'extreme') {
score += 5
}
// --- Wall absorbed (0β5) ---
if (wallAbsorbed) {
score += 5
}
return Math.max(30, Math.min(95, Math.round(score)))
}
// ======================== MERGE & DEDUPLICATE LEVELS ========================
/**
* Merge levels from all sources and deduplicate nearby ones (within 0.15%).
* Keeps the level with higher strength from each cluster.
*/
function mergeLevels(allLevels) {
if (allLevels.length === 0) return []
const sorted = [...allLevels].sort((a, b) => a.price - b.price)
const result = [sorted[0]]
for (let i = 1; i < sorted.length; i++) {
const prev = result[result.length - 1]
const pctDiff = Math.abs(sorted[i].price - prev.price) / prev.price
if (pctDiff < 0.0015) {
// Keep the stronger one
if (sorted[i].strength > prev.strength) {
result[result.length - 1] = sorted[i]
}
} else {
result.push(sorted[i])
}
}
return result
}
// ======================== SCAN LOOP ========================
const LIQ_SWEEP_SCAN_DELAY_MS = 150
const MIN_VOL_24H = 30_000_000
const MIN_VOLUME_RATIO = 5 // sweep candle volume must be >= 5x average
// In-memory 1h klines cache for sweep scanner (avoids 72 API calls per scan)
// Refreshed every 30min per symbol (1h candles don't change fast)
const _1hCache = new Map() // symbol β { candles, ts }
const _1H_CACHE_TTL = 30 * 60_000
const _1H_CACHE_MAX_AGE = 60 * 60_000 // evict entries older than 60min
// Periodic cleanup of stale 1h cache entries (every 10min)
setInterval(() => {
const now = Date.now()
let evicted = 0
for (const [symbol, entry] of _1hCache) {
if (now - entry.ts > _1H_CACHE_MAX_AGE) {
_1hCache.delete(symbol)
evicted++
}
}
if (evicted > 0) console.log(`[liq-sweep] Cache cleanup: evicted ${evicted} stale entries, ${_1hCache.size} remaining`)
}, 10 * 60_000)
/**
* Main scan function β called on a timer from signals.js.
* Examines the latest closed 5m candle per liquid symbol for sweep + pin bar.
*
* @param {Object} deps β injected dependencies (same DI pattern as signals.js)
* @param {Function} deps.getProxyCached
* @param {Function} deps.bgetWithRetry
* @param {Object} deps.klinesCache
* @param {Object} deps.stateManager
* @param {Object} deps.densityV2
* @param {Map} deps.persistenceMap
* @param {Function} deps.emitSignal
*/
async function scanLiqSweep(deps) {
const {
getProxyCached, bgetWithRetry, klinesCache,
stateManager, densityV2, persistenceMap, emitSignal,
getMarketRegime, getFundingMap,
} = deps
try {
// --- Get liquid symbols from cached ticker ---
let ticker = getProxyCached('ticker24hr', 60_000)
if (!Array.isArray(ticker) || ticker.length === 0) {
try { ticker = await bgetWithRetry('/fapi/v1/ticker/24hr') } catch { return }
if (!Array.isArray(ticker)) return
}
const liquid = ticker
.filter(t => t.symbol.endsWith('USDT') && !t.symbol.includes('_'))
.filter(t => parseFloat(t.quoteVolume) >= MIN_VOL_24H)
.sort((a, b) => parseFloat(b.quoteVolume) - parseFloat(a.quoteVolume))
// Get market regime once per scan (cached 5min)
const regime = getMarketRegime ? await getMarketRegime() : { direction: null }
// Funding rate map β reuse cached getFundingMap from signals.js (5min TTL)
// Values are raw decimals (e.g. 0.0003), multiply by 100 for percentage when comparing
const fundingRates = getFundingMap ? await getFundingMap() : {}
let signalCount = 0
let errCount = 0
let pinBarCount = 0
let sweepCount = 0
let volGateSkipped = 0
for (const t of liquid) {
const symbol = t.symbol
const markPrice = parseFloat(t.lastPrice)
if (!markPrice) continue
try {
// --- 1. Get 5m candles (cache first, API fallback) ---
let candles5m = klinesCache ? klinesCache.getCandles(symbol, '5m', 50) : []
if (candles5m.length < 10) {
// Fallback: fetch from Binance API (raw format)
const raw = await bgetWithRetry(`/fapi/v1/klines?symbol=${symbol}&interval=5m&limit=50`)
if (!Array.isArray(raw) || raw.length < 10) continue
candles5m = raw.map(k => ({
time: Math.floor(k[0] / 1000),
open: parseFloat(k[1]), high: parseFloat(k[2]),
low: parseFloat(k[3]), close: parseFloat(k[4]),
volume: parseFloat(k[7]), // quoteAssetVolume in USDT
}))
}
// The last candle may still be forming β examine the second-to-last (latest closed)
if (candles5m.length < 3) continue
const targetCandle = candles5m[candles5m.length - 2]
const prevCandles = candles5m.slice(Math.max(0, candles5m.length - 12), candles5m.length - 2) // 10 prior candles
// --- 2. Detect pin bar ---
const pinBar = detectPinBar(targetCandle, prevCandles)
if (!pinBar) continue
pinBarCount++
// --- 3. Gather liquidity levels ---
// 3a. Swing levels from 1h candles (SQLite cache β memory cache β API)
let candles1h = klinesCache ? klinesCache.getCandles(symbol, '1h', 200) : null
if (!candles1h || candles1h.length < 20) {
// Check in-memory 1h cache
const cached1h = _1hCache.get(symbol)
if (cached1h && Date.now() - cached1h.ts < _1H_CACHE_TTL) {
candles1h = cached1h.candles
} else {
try {
const raw1h = await bgetWithRetry(`/fapi/v1/klines?symbol=${symbol}&interval=1h&limit=200`)
if (Array.isArray(raw1h) && raw1h.length >= 20) {
candles1h = raw1h.map(k => ({
time: Math.floor(k[0] / 1000),
open: parseFloat(k[1]), high: parseFloat(k[2]),
low: parseFloat(k[3]), close: parseFloat(k[4]),
volume: parseFloat(k[7]),
}))
_1hCache.set(symbol, { candles: candles1h, ts: Date.now() })
} else { candles1h = null }
} catch { candles1h = null }
}
}
const swingLevels = candles1h ? findSwingLevels(candles1h, 5, 3) : []
// 3b. Order book walls β used as confluence boost, not standalone trigger
const wallLevels = getWallLevels({ symbol, markPrice, stateManager, densityV2, persistenceMap })
// 3c. Boost swing levels that coincide with walls (Β±0.2%)
for (const sw of swingLevels) {
const hasWallConfluence = wallLevels.some(w => Math.abs(w.price - sw.price) / sw.price < 0.002)
if (hasWallConfluence) {
sw.strength = (sw.strength || 1) + 3 // wall confluence bonus
sw.wallConfluence = true
}
}
// 3d. Only use swing levels (round numbers removed, walls are boost only)
const allLevels = mergeLevels(swingLevels)
if (allLevels.length === 0) continue
// --- 4. Confirm sweep ---
const sweep = confirmSweep(targetCandle, pinBar, allLevels)
if (!sweep) continue
sweepCount++
// --- 5. Volume ratio (candle vol vs SMA of prior candles) ---
// Gate: real sweeps ALWAYS have volume (stops/liquidations trigger).
// Skip anything below MIN_VOLUME_RATIO β it's just noise.
let volumeRatio = null
if (prevCandles.length >= 5) {
const avgVol = prevCandles.reduce((s, c) => s + (c.volume || 0), 0) / prevCandles.length
if (avgVol > 0 && targetCandle.volume) {
volumeRatio = targetCandle.volume / avgVol
}
}
if (volumeRatio == null || volumeRatio < MIN_VOLUME_RATIO) { volGateSkipped++; continue }
// --- 5b. OI change β confirms liquidations/stop hunts ---
// Only fetched for signals that passed all gates (saves API calls)
let oiChangePct = null
try {
const oiHist = await bgetWithRetry(
`/futures/data/openInterestHist?symbol=${symbol}&period=5min&limit=3`
)
if (Array.isArray(oiHist) && oiHist.length >= 2) {
const oiPrev = parseFloat(oiHist[oiHist.length - 2].sumOpenInterest)
const oiCurr = parseFloat(oiHist[oiHist.length - 1].sumOpenInterest)
if (oiPrev > 0) {
oiChangePct = ((oiCurr - oiPrev) / oiPrev) * 100
}
}
} catch { /* OI data unavailable β score without it */ }
// --- 5c. Trend context β counter-trend sweeps are stronger ---
// LONG sweep in BEARISH market = counter-trend exhaustion β strongest
// SHORT sweep in BULLISH market = counter-trend exhaustion β strongest
let trendContext = null
if (regime.direction) {
const isCounter =
(pinBar.direction === 'LONG' && regime.direction === 'BEARISH') ||
(pinBar.direction === 'SHORT' && regime.direction === 'BULLISH')
trendContext = isCounter ? 'counter' : 'with'
}
// --- 5d. Funding rate context ---
// Extreme funding + sweep against the crowd = smart money
// fundingRates values are raw decimals (0.0003 = 0.03%)
const rawFunding = fundingRates[symbol]
const fundingPct = rawFunding != null ? rawFunding * 100 : null
let fundingContext = null
if (fundingPct != null) {
if (fundingPct > 0.03 && pinBar.direction === 'SHORT') fundingContext = 'extreme'
else if (fundingPct < -0.02 && pinBar.direction === 'LONG') fundingContext = 'extreme'
}
// --- 6. Score confidence ---
const confidence = scoreConfidence({
wickRatio: pinBar.wickRatio,
levelStrength: sweep.strength,
levelsSwept: sweep.levelsSwept,
levelSource: sweep.levelSource,
volumeRatio,
oiChangePct,
wallAbsorbed: false,
trendContext,
fundingContext,
})
// --- 7. Emit signal ---
const candleTimeIso = new Date(targetCandle.time * 1000).toISOString()
emitSignal({
type: 'liq_sweep',
symbol,
price: markPrice,
signalTime: candleTimeIso,
direction: pinBar.direction,
confidence,
description: `π― ${pinBar.direction === 'LONG' ? 'Bullish' : 'Bearish'} sweep β wick took ${sweep.levelType.replace('_', ' ')} at ${sweep.sweptLevel}, recovered (${(pinBar.wickRatio * 100).toFixed(0)}% wick)`,
metadata: {
sweptLevel: sweep.sweptLevel,
levelType: sweep.levelType,
levelSource: sweep.levelSource,
sweepDepthPct: sweep.sweepDepthPct,
levelsSwept: sweep.levelsSwept,
wickRatio: pinBar.wickRatio,
bodyRatio: pinBar.bodyRatio,
candleRange: parseFloat(pinBar.range.toFixed(4)),
volumeRatio: volumeRatio ? parseFloat(volumeRatio.toFixed(1)) : null,
oiChangePct: oiChangePct != null ? parseFloat(oiChangePct.toFixed(2)) : null,
trendContext: trendContext || 'unknown',
fundingContext: fundingContext || 'normal',
fundingPct: fundingPct != null ? parseFloat(fundingPct.toFixed(4)) : null,
marketRegime: regime.direction || 'UNKNOWN',
wallNotional: sweep.notional,
change24h: parseFloat(t.priceChangePercent),
volume24h: Math.round(parseFloat(t.quoteVolume)),
},
})
signalCount++
} catch (e) {
errCount++
if (errCount <= 3) console.warn(`[LiqSweep] ${symbol} error:`, e.message)
}
await new Promise(r => setTimeout(r, LIQ_SWEEP_SCAN_DELAY_MS))
}
console.log(`[LiqSweep] Scan done: ${liquid.length} symbols | pinBars: ${pinBarCount}, sweeps: ${sweepCount}, volGate(<${MIN_VOLUME_RATIO}x): ${volGateSkipped}, signals: ${signalCount}${errCount ? ` [${errCount} errors]` : ''}`)
} catch (err) {
console.error('[LiqSweep] Scan error:', err.message)
}
}
// ======================== EXPORTS ========================
module.exports = {
findSwingLevels,
findRoundNumbers,
getWallLevels,
detectPinBar,
confirmSweep,
scoreConfidence,
scanLiqSweep,
// internal β exported for testing
mergeLevels,
clusterLevels,
getRoundStep,
}