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...