← Back
import { describe, it, expect, beforeEach } from 'vitest'
import {
  computeRegressionChannel,
  detectChannelSignal,
  calcConfidence,
  checkConfluence,
  recordSignalForConfluence,
  getTouchCount,
  recentChannelSignals,
} from '../server/channel-signal.js'

// Helper: generate ascending closes (linear trend + noise)
function makeLinearCloses(n, start, slopePerCandle, noise = 0) {
  return Array.from({ length: n }, (_, i) => start + slopePerCandle * i + (Math.random() - 0.5) * noise)
}

// Helper: generate candles from closes
function makeCandles(closes, volumeBase = 1000) {
  return closes.map((c, i) => ({
    time: 1000 + i * 60000,
    open: c - 0.5,
    high: c + 1,
    low: c - 1,
    close: c,
    volume: volumeBase,
  }))
}

// ---- computeRegressionChannel ----
describe('computeRegressionChannel', () => {
  it('returns null for fewer than 30 candles', () => {
    const closes = Array.from({ length: 20 }, (_, i) => 100 + i)
    expect(computeRegressionChannel(closes)).toBeNull()
  })

  it('computes channel for linear ascending data', () => {
    // Nearly linear with slight noise (sigma > 0)
    const closes = Array.from({ length: 100 }, (_, i) => 100 + 0.5 * i + Math.sin(i) * 0.5)
    const ch = computeRegressionChannel(closes)
    expect(ch).not.toBeNull()
    expect(ch.r2).toBeGreaterThan(0.95)
    expect(ch.slopePct).toBeGreaterThan(0)
    expect(ch.slopeDir).toBe('up')
    expect(ch.upper).toBeGreaterThan(ch.mid)
    expect(ch.lower).toBeLessThan(ch.mid)
  })

  it('computes channel for linear descending data', () => {
    const closes = Array.from({ length: 100 }, (_, i) => 200 - 0.5 * i)
    const ch = computeRegressionChannel(closes)
    expect(ch).not.toBeNull()
    expect(ch.slopeDir).toBe('down')
    expect(ch.slopePct).toBeLessThan(0)
  })

  it('detects flat channel for sideways data', () => {
    // Flat: oscillate symmetrically around 100 with enough amplitude for high R²
    // Use a symmetric wave that doesn't drift
    const closes = Array.from({ length: 100 }, (_, i) => 100 + Math.sin(i * 0.3) * 3)
    const ch = computeRegressionChannel(closes)
    // With symmetric oscillation, regression slope should be near-zero
    // But R² may be too low → null is acceptable for flat data
    if (ch) {
      // If channel detected, slope should be flat or very mild
      expect(Math.abs(ch.slopePct)).toBeLessThan(0.05)
    }
  })

  it('returns null for random noise (low R²)', () => {
    const closes = Array.from({ length: 100 }, () => 100 + (Math.random() - 0.5) * 50)
    const ch = computeRegressionChannel(closes)
    // High noise → low R² → null
    expect(ch).toBeNull()
  })

  it('includes bandWidthPct', () => {
    const closes = Array.from({ length: 100 }, (_, i) => 100 + 0.3 * i + Math.sin(i * 0.5) * 2)
    const ch = computeRegressionChannel(closes)
    if (ch) {
      expect(ch.bandWidthPct).toBeGreaterThan(0)
      expect(ch.bandWidth).toBe(ch.upper - ch.lower)
    }
  })
})

// ---- detectChannelSignal ----
describe('detectChannelSignal', () => {
  it('returns null for insufficient candles', () => {
    expect(detectChannelSignal(null, 1000, '5m')).toBeNull()
    expect(detectChannelSignal([], 1000, '5m')).toBeNull()
    expect(detectChannelSignal(makeCandles([1, 2, 3]), 1000, '5m')).toBeNull()
  })

  it('detects bounce from lower band in ascending channel', () => {
    // Create ascending channel, then last candle touches lower band
    const n = 100
    const closes = Array.from({ length: n }, (_, i) => 100 + 0.3 * i + Math.sin(i * 0.3) * 1.5)
    const candles = makeCandles(closes, 1500)

    // Compute channel to know where lower band is
    const ch = computeRegressionChannel(closes)
    if (!ch) return // skip if R² too low for this random seed

    // Modify last candle to touch lower band with wick rejection
    const lastIdx = candles.length - 1
    candles[lastIdx] = {
      ...candles[lastIdx],
      low: ch.lower * 0.998, // penetrate lower band
      close: ch.lower + (ch.mid - ch.lower) * 0.3, // close back inside
      open: ch.lower + (ch.mid - ch.lower) * 0.2,
      high: ch.lower + (ch.mid - ch.lower) * 0.4,
      volume: 2000,
    }

    const volumeSma = 1000
    const sig = detectChannelSignal(candles, volumeSma, '5m')
    // May or may not detect depending on exact channel params
    if (sig) {
      expect(sig.direction).toBe('LONG')
      expect(sig.subType).toBe('channel_bounce')
    }
  })

  it('detects reversal breakout in descending channel', () => {
    const n = 100
    // Descending channel
    const closes = Array.from({ length: n }, (_, i) => 200 - 0.4 * i + Math.sin(i * 0.3) * 1)
    const candles = makeCandles(closes, 1000)

    const ch = computeRegressionChannel(closes)
    if (!ch || ch.slopeDir !== 'down') return

    // Last candle breaks above upper band with high volume
    const lastIdx = candles.length - 1
    const prevIdx = lastIdx - 1
    candles[prevIdx].close = ch.upper * 0.999 // prev was inside
    candles[lastIdx] = {
      ...candles[lastIdx],
      open: ch.upper * 0.999,
      close: ch.upper * 1.005, // break above
      high: ch.upper * 1.008,
      low: ch.upper * 0.995,
      volume: 3000, // high volume for reversal gate (2x)
    }

    const volumeSma = 1000
    const sig = detectChannelSignal(candles, volumeSma, '5m')
    if (sig) {
      expect(sig.direction).toBe('LONG')
      expect(sig.subType).toBe('channel_reversal')
    }
  })

  it('returns null when candle range is zero', () => {
    const closes = Array.from({ length: 100 }, (_, i) => 100 + 0.3 * i)
    const candles = makeCandles(closes)
    // Set last candle to zero range
    const last = candles.length - 1
    candles[last] = { ...candles[last], high: 130, low: 130, open: 130, close: 130 }
    expect(detectChannelSignal(candles, 1000, '5m')).toBeNull()
  })
})

// ---- calcConfidence ----
describe('calcConfidence', () => {
  const baseChannel = {
    r2: 0.85, slopeClass: 'mild', slopeDir: 'up',
    upper: 110, lower: 90, mid: 100, bandWidthPct: 2,
  }

  it('returns base confidence for bounce', () => {
    const sig = { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: baseChannel }
    const conf = calcConfidence(sig, 1, { count: 1 }, null)
    expect(conf).toBeGreaterThanOrEqual(35)
    expect(conf).toBeLessThanOrEqual(95)
  })

  it('boosts confidence for wick rejection on bounce', () => {
    const noWick = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: baseChannel },
      1, { count: 1 }, null
    )
    const withWick = calcConfidence(
      { subType: 'channel_bounce', wickRejection: true, volRatio: 1.2, channel: baseChannel },
      1, { count: 1 }, null
    )
    expect(withWick).toBeGreaterThan(noWick)
  })

  it('boosts for high R²', () => {
    const lowR2 = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: { ...baseChannel, r2: 0.66 } },
      1, { count: 1 }, null
    )
    const highR2 = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: { ...baseChannel, r2: 0.94 } },
      1, { count: 1 }, null
    )
    expect(highR2).toBeGreaterThan(lowR2)
  })

  it('boosts for strong slope', () => {
    const mild = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: { ...baseChannel, slopeClass: 'mild' } },
      1, { count: 1 }, null
    )
    const strong = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: { ...baseChannel, slopeClass: 'strong' } },
      1, { count: 1 }, null
    )
    expect(strong).toBeGreaterThan(mild)
  })

  it('boosts for multi-TF confluence', () => {
    const no = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: baseChannel },
      1, { count: 1 }, null
    )
    const multi = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: baseChannel },
      1, { count: 3 }, null
    )
    expect(multi).toBeGreaterThan(no)
  })

  it('boosts for BTC alignment', () => {
    const noRegime = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, direction: 'LONG', channel: baseChannel },
      1, { count: 1 }, null
    )
    const aligned = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, direction: 'LONG', channel: baseChannel },
      1, { count: 1 }, { direction: 'BULLISH' }
    )
    expect(aligned).toBeGreaterThan(noRegime)
  })

  it('penalizes bounce with 4+ touches', () => {
    const few = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: baseChannel },
      2, { count: 1 }, null
    )
    const many = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.2, channel: baseChannel },
      4, { count: 1 }, null
    )
    expect(many).toBeLessThan(few) // -5 for 4+ bounces
  })

  it('clamps to CONF_MIN-CONF_MAX', () => {
    // Everything at minimum
    const low = calcConfidence(
      { subType: 'channel_bounce', wickRejection: false, volRatio: 1.0, channel: { ...baseChannel, r2: 0.65, slopeClass: 'flat' } },
      4, { count: 1 }, null
    )
    expect(low).toBeGreaterThanOrEqual(35)

    // Everything maxed
    const high = calcConfidence(
      { subType: 'channel_bounce', wickRejection: true, volRatio: 10, direction: 'LONG', channel: { ...baseChannel, r2: 0.95, slopeClass: 'strong' } },
      3, { count: 3 }, { direction: 'BULLISH' }
    )
    expect(high).toBeLessThanOrEqual(95)
  })
})

// ---- checkConfluence ----
describe('checkConfluence', () => {
  beforeEach(() => {
    recentChannelSignals.length = 0
  })

  it('returns count=1 when no other signals', () => {
    const result = checkConfluence('BTCUSDT', 'LONG', 'channel_bounce', '5m')
    expect(result.count).toBe(1) // only current TF
    expect(result.timeframes).toEqual(['5m'])
  })

  it('counts matching signals from different timeframes', () => {
    recordSignalForConfluence('BTCUSDT', 'LONG', 'channel_bounce', '15m')
    recordSignalForConfluence('BTCUSDT', 'LONG', 'channel_bounce', '1h')

    const result = checkConfluence('BTCUSDT', 'LONG', 'channel_bounce', '5m')
    expect(result.count).toBe(3) // 5m + 15m + 1h
    expect(result.timeframes).toContain('5m')
    expect(result.timeframes).toContain('15m')
    expect(result.timeframes).toContain('1h')
  })

  it('ignores different symbol', () => {
    recordSignalForConfluence('ETHUSDT', 'LONG', 'channel_bounce', '15m')
    const result = checkConfluence('BTCUSDT', 'LONG', 'channel_bounce', '5m')
    expect(result.count).toBe(1) // only current
  })

  it('ignores different direction', () => {
    recordSignalForConfluence('BTCUSDT', 'SHORT', 'channel_bounce', '15m')
    const result = checkConfluence('BTCUSDT', 'LONG', 'channel_bounce', '5m')
    expect(result.count).toBe(1)
  })

  it('ignores same timeframe (no self-confluence)', () => {
    recordSignalForConfluence('BTCUSDT', 'LONG', 'channel_bounce', '5m')
    const result = checkConfluence('BTCUSDT', 'LONG', 'channel_bounce', '5m')
    expect(result.count).toBe(1) // still 1 — same TF filtered out
  })
})

// ---- getTouchCount ----
describe('getTouchCount', () => {
  it('counts approaches to upper band', () => {
    // Flat channel: mid=100, sigma=5, BAND_MULT=2 → upper=110, lower=90
    const channel = { upper: 110, lower: 90, period: 50, intercept: 100, slope: 0, sigma: 5 }
    // Create candles, some touching upper band
    const candles = Array.from({ length: 50 }, (_, i) => ({
      high: i % 10 === 0 ? 109.8 : 105, // every 10th candle touches upper zone
      low: 95,
      close: 100,
    }))
    const touches = getTouchCount('BTCUSDT', 'upper', '5m', channel, candles)
    expect(touches).toBeGreaterThan(0)
  })

  it('counts approaches to lower band', () => {
    const channel = { upper: 110, lower: 90, period: 50, intercept: 100, slope: 0, sigma: 5 }
    const candles = Array.from({ length: 50 }, (_, i) => ({
      high: 105,
      low: i % 10 === 0 ? 90.2 : 95, // touches lower zone
      close: 100,
    }))
    const touches = getTouchCount('BTCUSDT', 'lower', '5m', channel, candles)
    expect(touches).toBeGreaterThan(0)
  })

  it('requires min 3 candles between touches', () => {
    const channel = { upper: 110, lower: 90, period: 10, intercept: 100, slope: 0, sigma: 5 }
    // All candles touch upper — but only counted every 3
    const candles = Array.from({ length: 10 }, () => ({
      high: 110, low: 95, close: 100,
    }))
    const touches = getTouchCount('BTCUSDT', 'upper', '5m', channel, candles)
    expect(touches).toBeLessThanOrEqual(4) // 10/3 = 3.3 → max 4
  })
})

📜 Git History

562f6d2fix: 14-point signal system audit — critical bugs + UX fixes8 weeks ago
4492574fix+test: comprehensive code audit — 11 bugfixes + 148 new tests8 weeks ago
Show last diff
Loading...