← Back
β˜†
'use strict'
const { createLogger } = require('./logger')
const log = createLogger('resilience')

/**
 * Market Resilience β€” Order Book Recovery Speed
 *
 * Measures how stable and resilient the order book is by tracking
 * total depth near mark price (Β±0.5%) over time.
 *
 * Metrics per symbol:
 *   - depthScore: current total depth (bid+ask) within Β±0.5% ($)
 *   - stability: 1 - CV (coefficient of variation) of depth over window
 *     1.0 = perfectly stable, 0 = wildly varying
 *   - recoveryEvents: count of depth-drop-then-recover events
 *   - avgRecoveryMs: average time to recover 80% of pre-drop depth
 *
 * Checks every 10 seconds, rolling 15-minute window (90 samples).
 */

const CHECK_INTERVAL_MS = 10_000
const WINDOW_SIZE = 90            // 15 min of 10s samples
const DEPTH_WINDOW_PCT = 0.5      // Β±0.5% from mark price
const DROP_THRESHOLD = 0.4        // 40% drop = impact event
const RECOVERY_THRESHOLD = 0.8    // 80% recovery = recovered
const CLEANUP_INTERVAL_MS = 120_000

let _stateManager = null
let _getProxyCached = null
let _checkInterval = null
let _cleanupInterval = null

// Mark price cache
let _markMap = new Map()
let _markTs = 0

// symbol -> { samples: [depth...], recoveries: [{dropTs, recoveryMs}], currentDrop: {ts, baseline} | null }
const tracked = new Map()

function init({ stateManager, getProxyCached }) {
  _stateManager = stateManager
  _getProxyCached = getProxyCached

  _checkInterval = setInterval(checkResilience, CHECK_INTERVAL_MS)
  _cleanupInterval = setInterval(cleanupIdle, CLEANUP_INTERVAL_MS)

  log.info({ intervalSec: CHECK_INTERVAL_MS / 1000, windowSamples: WINDOW_SIZE }, 'Resilience tracker started')
}

function stop() {
  if (_checkInterval) { clearInterval(_checkInterval); _checkInterval = null }
  if (_cleanupInterval) { clearInterval(_cleanupInterval); _cleanupInterval = null }
  tracked.clear()
  log.info('Resilience 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
  }
}

/**
 * Measure total depth (bid + ask notional) within Β±windowPct% of mark price
 */
function measureDepth(book, markPrice) {
  const minP = markPrice * (1 - DEPTH_WINDOW_PCT / 100)
  const maxP = markPrice * (1 + DEPTH_WINDOW_PCT / 100)
  let total = 0

  for (const sideMap of [book.bids, book.asks]) {
    if (!sideMap) continue
    for (const [price, data] of sideMap) {
      if (price >= minP && price <= maxP && data.notional > 0) {
        total += data.notional
      }
    }
  }
  return total
}

/**
 * Main check loop
 */
function checkResilience() {
  if (!_stateManager) return
  refreshMarkPrices()
  const now = Date.now()

  for (const [symbol, book] of _stateManager.books) {
    const markPrice = _markMap.get(symbol)
    if (!markPrice) continue

    const depth = measureDepth(book, markPrice)
    if (depth <= 0) continue

    if (!tracked.has(symbol)) {
      tracked.set(symbol, { samples: [], recoveries: [], currentDrop: null, lastActivity: now })
    }

    const entry = tracked.get(symbol)
    entry.lastActivity = now
    entry.samples.push(depth)

    // Trim to window
    if (entry.samples.length > WINDOW_SIZE) {
      entry.samples = entry.samples.slice(-WINDOW_SIZE)
    }

    // Need at least 6 samples for meaningful analysis
    if (entry.samples.length < 6) continue

    // Compute rolling average (last 6 samples = ~60s baseline)
    const recentBaseline = entry.samples.slice(-7, -1)
    const avgBaseline = recentBaseline.reduce((s, v) => s + v, 0) / recentBaseline.length

    // Detect depth drop (impact event)
    if (!entry.currentDrop && depth < avgBaseline * (1 - DROP_THRESHOLD)) {
      entry.currentDrop = { ts: now, baseline: avgBaseline }
    }

    // Detect recovery
    if (entry.currentDrop && depth >= entry.currentDrop.baseline * RECOVERY_THRESHOLD) {
      const recoveryMs = now - entry.currentDrop.ts
      entry.recoveries.push({ dropTs: entry.currentDrop.ts, recoveryMs })
      // Keep last 20 recovery events
      if (entry.recoveries.length > 20) entry.recoveries = entry.recoveries.slice(-20)
      entry.currentDrop = null
    }

    // Timeout stale drops (>5 min without recovery = failed)
    if (entry.currentDrop && now - entry.currentDrop.ts > 300_000) {
      entry.recoveries.push({ dropTs: entry.currentDrop.ts, recoveryMs: 300_000 }) // cap at 5min
      entry.currentDrop = null
    }
  }
}

/**
 * Get resilience data for a symbol
 */
function getData(symbol) {
  const entry = tracked.get(symbol)
  if (!entry || entry.samples.length < 6) return null

  const samples = entry.samples
  const mean = samples.reduce((s, v) => s + v, 0) / samples.length
  const variance = samples.reduce((s, v) => s + (v - mean) ** 2, 0) / samples.length
  const stddev = Math.sqrt(variance)
  const cv = mean > 0 ? stddev / mean : 0
  const stability = Math.max(0, Math.min(1, 1 - cv))

  const avgRecoveryMs = entry.recoveries.length > 0
    ? entry.recoveries.reduce((s, r) => s + r.recoveryMs, 0) / entry.recoveries.length
    : null

  const current = samples[samples.length - 1]

  return {
    symbol,
    depthScore: Math.round(current),
    depthAvg: Math.round(mean),
    stability: +stability.toFixed(3),
    recoveryEvents: entry.recoveries.length,
    avgRecoverySec: avgRecoveryMs != null ? +(avgRecoveryMs / 1000).toFixed(1) : null,
    inDrop: !!entry.currentDrop,
    samples: samples.length,
  }
}

/**
 * Get all tracked symbols sorted by stability (most stable first)
 */
function getAll() {
  const results = []
  for (const [symbol] of tracked) {
    const data = getData(symbol)
    if (data) results.push(data)
  }
  results.sort((a, b) => b.stability - a.stability)
  return results
}

/**
 * Cleanup idle symbols
 */
function cleanupIdle() {
  const cutoff = Date.now() - 300_000 // 5 min idle
  for (const [symbol, entry] of tracked) {
    if (entry.lastActivity < cutoff) tracked.delete(symbol)
  }
}

function getStats() {
  const all = getAll()
  const fragile = all.filter(d => d.stability < 0.7)
  return {
    tracked: tracked.size,
    withData: all.length,
    fragile: fragile.length,
    top5Resilient: all.slice(0, 5).map(d => ({ s: d.symbol, stab: d.stability, depth: d.depthScore })),
    top5Fragile: all.slice(-5).reverse().map(d => ({ s: d.symbol, stab: d.stability, depth: d.depthScore })),
  }
}

module.exports = { init, stop, getData, getAll, getStats }

πŸ“œ Git History

07d5cecfeat: market resilience tracker (book stability + recovery speed)8 weeks ago
Show last diff
Loading...