'use strict'
const { createLogger } = require('./logger')
const log = createLogger('fill-kill')
/**
* Fill:Kill Ratio — Wall Authenticity / Spoof Detection
*
* Tracks significant order book walls over time:
* - If a wall disappears AND price crossed it → FILLED (real demand/supply)
* - If a wall disappears AND price did NOT cross it → KILLED (cancelled/spoof)
*
* Fill:Kill > 0.5 = genuine walls (real liquidity)
* Fill:Kill < 0.3 = likely spoofing (fake walls to manipulate)
*
* Uses stateManager.books for wall tracking, ticker24hr for mark prices.
* Checks every 10 seconds, rolling 30-minute window per symbol.
*/
const CHECK_INTERVAL_MS = 10_000 // check every 10s
const MIN_NOTIONAL = 50_000 // $50K minimum to track as "wall"
const WINDOW_PCT = 3 // ±3% from mark price
const HISTORY_WINDOW_MS = 30 * 60_000 // 30 min rolling window
const CLEANUP_INTERVAL_MS = 60_000
let _stateManager = null
let _getProxyCached = null
let _checkInterval = null
let _cleanupInterval = null
// symbol -> { walls: Map<priceKey, {price, notional, side, firstSeen, lastSeen}>, events: [{ts, type, price, notional, side}], stats: {filled, killed} }
const tracked = new Map()
// Mark price cache
let _markMap = new Map()
let _markTs = 0
function init({ stateManager, getProxyCached }) {
_stateManager = stateManager
_getProxyCached = getProxyCached
_checkInterval = setInterval(checkWalls, CHECK_INTERVAL_MS)
_cleanupInterval = setInterval(cleanupOld, CLEANUP_INTERVAL_MS)
log.info({ intervalSec: CHECK_INTERVAL_MS / 1000, minNotional: MIN_NOTIONAL }, 'Fill:Kill tracker started')
}
function stop() {
if (_checkInterval) { clearInterval(_checkInterval); _checkInterval = null }
if (_cleanupInterval) { clearInterval(_cleanupInterval); _cleanupInterval = null }
tracked.clear()
log.info('Fill:Kill tracker stopped')
}
function refreshMarkPrices() {
const now = Date.now()
if (now - _markTs < 15_000) return
const tickers = _getProxyCached && _getProxyCached('ticker24hr', 60_000)
if (Array.isArray(tickers)) {
for (const t of tickers) {
const p = parseFloat(t.lastPrice)
if (p > 0) _markMap.set(t.symbol, p)
}
_markTs = now
}
}
/**
* Main check loop — compare current walls vs previous snapshot
*/
function checkWalls() {
if (!_stateManager) return
refreshMarkPrices()
const now = Date.now()
for (const [symbol, book] of _stateManager.books) {
const markPrice = _markMap.get(symbol)
if (!markPrice) continue
if (!tracked.has(symbol)) {
tracked.set(symbol, { walls: new Map(), events: [], stats: { filled: 0, killed: 0 } })
}
const entry = tracked.get(symbol)
const prevWalls = entry.walls
const currentWalls = new Map()
const minPrice = markPrice * (1 - WINDOW_PCT / 100)
const maxPrice = markPrice * (1 + WINDOW_PCT / 100)
// Scan current book for significant walls
for (const [side, sideMap] of [['bid', book.bids], ['ask', book.asks]]) {
if (!sideMap) continue
for (const [price, data] of sideMap) {
if (price < minPrice || price > maxPrice) continue
if (!data.notional || data.notional < MIN_NOTIONAL) continue
const key = `${side}:${price}`
currentWalls.set(key, {
price, notional: data.notional, side,
firstSeen: data.firstSeen || now, lastSeen: now,
})
}
}
// Compare: find walls that disappeared
for (const [key, prevWall] of prevWalls) {
if (currentWalls.has(key)) continue // still there
// Wall disappeared — was it filled or killed?
const { price, side, notional } = prevWall
let type = 'killed' // default: cancelled/spoofed
if (side === 'bid' && markPrice <= price) {
type = 'filled' // price dropped through bid wall → filled
} else if (side === 'ask' && markPrice >= price) {
type = 'filled' // price rose through ask wall → filled
}
entry.events.push({ ts: now, type, price, notional, side })
entry.stats[type]++
}
// Update snapshot
entry.walls = currentWalls
}
}
/**
* Cleanup old events beyond rolling window
*/
function cleanupOld() {
const cutoff = Date.now() - HISTORY_WINDOW_MS
for (const [symbol, entry] of tracked) {
if (!entry.events.length) continue
// Recount stats from remaining events
const remaining = entry.events.filter(e => e.ts > cutoff)
if (remaining.length !== entry.events.length) {
entry.events = remaining
entry.stats = { filled: 0, killed: 0 }
for (const e of remaining) entry.stats[e.type]++
}
// Remove symbols with no walls and no events
if (!entry.walls.size && !entry.events.length) {
tracked.delete(symbol)
}
}
}
/**
* Get Fill:Kill data for a single symbol
*/
function getData(symbol) {
const entry = tracked.get(symbol)
if (!entry) return null
const total = entry.stats.filled + entry.stats.killed
const ratio = total > 0 ? entry.stats.filled / total : null
return {
symbol,
fillKillRatio: ratio != null ? +ratio.toFixed(3) : null,
filled: entry.stats.filled,
killed: entry.stats.killed,
total,
activeWalls: entry.walls.size,
recentEvents: entry.events.slice(-20).reverse().map(e => ({
ts: e.ts, type: e.type, side: e.side,
price: e.price, notional: Math.round(e.notional),
})),
}
}
/**
* Get all symbols with Fill:Kill data, sorted by ratio ascending (most spoofed first)
*/
function getAll() {
const results = []
for (const [symbol] of tracked) {
const data = getData(symbol)
if (data && data.total >= 3) results.push(data) // min 3 events for meaningful ratio
}
results.sort((a, b) => (a.fillKillRatio || 0) - (b.fillKillRatio || 0))
return results
}
/**
* Stats for monitoring
*/
function getStats() {
const all = getAll()
const spoofed = all.filter(d => d.fillKillRatio != null && d.fillKillRatio < 0.3)
const genuine = all.filter(d => d.fillKillRatio != null && d.fillKillRatio > 0.5)
return {
trackedSymbols: tracked.size,
withData: all.length,
spoofSuspect: spoofed.length,
genuineWalls: genuine.length,
top5Spoofed: spoofed.slice(0, 5).map(d => ({ symbol: d.symbol, ratio: d.fillKillRatio, filled: d.filled, killed: d.killed })),
top5Genuine: genuine.slice(-5).reverse().map(d => ({ symbol: d.symbol, ratio: d.fillKillRatio, filled: d.filled, killed: d.killed })),
}
}
module.exports = { init, stop, getData, getAll, getStats }
📜 Git History
8813c91feat: Fill:Kill ratio tracker (wall authenticity / spoof detection)8 weeks ago
Show last diff
Loading...