package usecase import ( "strconv" "testing" "cryptoHermes/internal/entity" "github.com/stretchr/testify/require" ) // ---- 测试辅助 ---------------------------------------------------------- // mkKline 构造一根 1h K 线;只填指标关心的字段。 // openTime 单位毫秒;hour 序号映射到 1h 时基。 func mkKline(hourIdx int64, open, high, low, close string) entity.Kline { const hourMs = int64(3600 * 1000) return entity.Kline{ Symbol: "BTCUSDT", Interval: "1h", OpenTime: hourIdx * hourMs, CloseTime: hourIdx*hourMs + hourMs - 1, Open: open, High: high, Low: low, Close: close, IsClosed: true, } } func mkLSR(hourIdx int64, ratio string) entity.LongShortRatio { const hourMs = int64(3600 * 1000) return entity.LongShortRatio{ Symbol: "BTCUSDT", Period: "1h", RatioType: entity.RatioTypeGlobalAccount, Timestamp: hourIdx * hourMs, LongShortRatio: ratio, } } // 单调上升序列:从 baseHigh 起,每根 K 线 high/low/close 各 +stepUSD。 // 共 n 根,hourIdx 从 0 起。 func ascending(n int, baseLow, step float64) []entity.Kline { out := make([]entity.Kline, n) for i := 0; i < n; i++ { lo := baseLow + float64(i)*step hi := lo + step mid := (lo + hi) / 2 out[i] = mkKline(int64(i), strconv.FormatFloat(lo, 'f', -1, 64), strconv.FormatFloat(hi, 'f', -1, 64), strconv.FormatFloat(lo, 'f', -1, 64), strconv.FormatFloat(mid, 'f', -1, 64), ) } return out } func descending(n int, baseHigh, step float64) []entity.Kline { out := make([]entity.Kline, n) for i := 0; i < n; i++ { hi := baseHigh - float64(i)*step lo := hi - step mid := (lo + hi) / 2 out[i] = mkKline(int64(i), strconv.FormatFloat(hi, 'f', -1, 64), strconv.FormatFloat(hi, 'f', -1, 64), strconv.FormatFloat(lo, 'f', -1, 64), strconv.FormatFloat(mid, 'f', -1, 64), ) } return out } // ---- parsePrice ------------------------------------------------------- func TestParsePrice(t *testing.T) { cases := []struct { name string in string want float64 ok bool }{ {"normal", "108000.12", 108000.12, true}, {"zero", "0", 0, true}, {"negative", "-1.5", -1.5, true}, {"scientific", "1e3", 1000, true}, {"empty", "", 0, false}, {"non-numeric", "abc", 0, false}, {"whitespace", " 1.0 ", 0, false}, {"nan", "NaN", 0, false}, {"posinf", "Inf", 0, false}, {"neginf", "-Inf", 0, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, ok := parsePrice(c.in) require.Equal(t, c.ok, ok, "ok mismatch for %q", c.in) if c.ok { require.InDelta(t, c.want, got, 1e-9) } }) } } // ---- formatPrice ------------------------------------------------------ func TestFormatPrice(t *testing.T) { require.Equal(t, "0", formatPrice(0)) require.Equal(t, "1.5", formatPrice(1.5)) require.Equal(t, "108000.12", formatPrice(108000.12)) require.Equal(t, "-3", formatPrice(-3)) } // ---- percentile ------------------------------------------------------- func TestPercentile(t *testing.T) { // type-7 linear interpolation (NumPy default) require.Equal(t, 0.0, percentile(nil, 0.5), "empty → 0") require.Equal(t, 42.0, percentile([]float64{42}, 0.95), "N=1") // N=2: P95: rank=(2-1)*0.95=0.95, between [0]=10 and [1]=20 → 10 + 0.95*10 = 19.5 require.InDelta(t, 19.5, percentile([]float64{10, 20}, 0.95), 1e-9) require.InDelta(t, 10.5, percentile([]float64{10, 20}, 0.05), 1e-9) // N=20: rank=19*0.95=18.05, between [18] and [19] xs := make([]float64, 20) for i := range xs { xs[i] = float64(i + 1) // 1..20 } // rank=18.05 → 19 + 0.05*(20-19) = 19.05 require.InDelta(t, 19.05, percentile(xs, 0.95), 1e-9) // rank=0.95 → 1 + 0.95*(2-1) = 1.95 require.InDelta(t, 1.95, percentile(xs, 0.05), 1e-9) // 边界 p:p<=0 → 第一个元素;p>=1 → 最后元素 require.Equal(t, 1.0, percentile(xs, 0.0)) require.Equal(t, 1.0, percentile(xs, -0.5)) require.Equal(t, 20.0, percentile(xs, 1.0)) require.Equal(t, 20.0, percentile(xs, 1.5)) } // ---- medianFloat ------------------------------------------------------ func TestMedianFloat(t *testing.T) { require.Equal(t, 0.0, medianFloat(nil)) require.Equal(t, 5.0, medianFloat([]float64{5})) require.InDelta(t, 7.5, medianFloat([]float64{5, 10}), 1e-9) require.InDelta(t, 5.0, medianFloat([]float64{1, 5, 9}), 1e-9) require.InDelta(t, 4.0, medianFloat([]float64{9, 1, 5, 3}), 1e-9) // unsorted input } // ---- pivotHighs / pivotLows ------------------------------------------ func TestPivot_EmptyAndShortInputs(t *testing.T) { require.Empty(t, pivotHighs(nil, 5, 5)) require.Empty(t, pivotLows(nil, 5, 5)) require.Empty(t, pivotHighs(ascending(5, 100, 1), 5, 5), "N < 2L+1") require.Empty(t, pivotLows(ascending(10, 100, 1), 5, 5), "N < 2L+1") } func TestPivot_MonotonicNoPivots(t *testing.T) { asc := ascending(30, 100, 10) require.Empty(t, pivotHighs(asc, 5, 5), "ascending has no swing high in interior") require.Empty(t, pivotLows(asc, 5, 5), "ascending has no swing low in interior") desc := descending(30, 100, 1) require.Empty(t, pivotHighs(desc, 5, 5)) require.Empty(t, pivotLows(desc, 5, 5)) } func TestPivot_VShape(t *testing.T) { // 构造 V 形:前 10 根 high 递减、后 10 根递增;中间最低点应为唯一 pivotLow klines := make([]entity.Kline, 21) for i := 0; i < 21; i++ { // price 形态:100 -> 90 (index 10) -> 100 var p float64 if i <= 10 { p = 100 - float64(i)*1.0 } else { p = 90 + float64(i-10)*1.0 } hi := p + 0.5 lo := p mid := p + 0.25 klines[i] = mkKline(int64(i), strconv.FormatFloat(p, 'f', -1, 64), strconv.FormatFloat(hi, 'f', -1, 64), strconv.FormatFloat(lo, 'f', -1, 64), strconv.FormatFloat(mid, 'f', -1, 64), ) } highs := pivotHighs(klines, 5, 5) lows := pivotLows(klines, 5, 5) require.Empty(t, highs, "V-shape has no pivot high") require.Len(t, lows, 1, "V-shape has exactly one pivot low") require.Equal(t, 10, lows[0].Index) require.InDelta(t, 90.0, lows[0].Price, 1e-9) } func TestPivot_DoubleTop(t *testing.T) { // 两个明确的 pivot high:index 7 和 index 15 // 用价格谷-峰-谷-峰-谷 结构 klines := make([]entity.Kline, 23) // 默认平的低位 100 for i := range klines { klines[i] = mkKline(int64(i), "100", "100.5", "99.5", "100") } // peak1 at i=7, price 110 klines[7] = mkKline(7, "109", "110", "108", "109.5") // peak2 at i=15, price 110.2 (0.18% 距离,<0.3% 阈值) klines[15] = mkKline(15, "109", "110.2", "108", "109.5") highs := pivotHighs(klines, 5, 5) require.Len(t, highs, 2) require.Equal(t, 7, highs[0].Index) require.Equal(t, 15, highs[1].Index) } // ---- clusterLevels ---------------------------------------------------- func TestClusterLevels_Empty(t *testing.T) { require.Empty(t, clusterLevels(nil, 0.003, sourceResistancePivot)) } func TestClusterLevels_DoubleTopWithinThreshold(t *testing.T) { // 两个点相差 0.18%(< 0.3%)→ 合并为 1 个 level,Strength="2" pts := []pivotPoint{ {Price: 110.0, Index: 7}, {Price: 110.2, Index: 15}, } out := clusterLevels(pts, 0.003, sourceResistancePivot) require.Len(t, out, 1) require.Equal(t, "2", out[0].Strength) require.Equal(t, sourceResistancePivot, out[0].Source) // 均价 = 110.1 p, ok := parsePrice(out[0].Price) require.True(t, ok) require.InDelta(t, 110.1, p, 1e-6) } func TestClusterLevels_DoubleTopOutsideThreshold(t *testing.T) { // 两个点相差 0.45%(> 0.3%)→ 不合并 pts := []pivotPoint{ {Price: 110.0, Index: 7}, {Price: 110.5, Index: 15}, } out := clusterLevels(pts, 0.003, sourceResistancePivot) require.Len(t, out, 2) require.Equal(t, "1", out[0].Strength) require.Equal(t, "1", out[1].Strength) } func TestClusterLevels_TripleCluster(t *testing.T) { pts := []pivotPoint{ {Price: 100.0}, {Price: 100.15}, {Price: 100.25}, } // 100/100.25 相差 0.25%,都在阈值内 out := clusterLevels(pts, 0.003, sourceResistancePivot) require.Len(t, out, 1) require.Equal(t, "3", out[0].Strength) } // ---- rangeHighLow ----------------------------------------------------- func TestRangeHighLow_Empty(t *testing.T) { hi, lo := rangeHighLow(nil) require.Nil(t, hi) require.Nil(t, lo) } func TestRangeHighLow_SingleCandle(t *testing.T) { k := []entity.Kline{mkKline(0, "100", "105", "95", "102")} hi, lo := rangeHighLow(k) require.NotNil(t, hi) require.NotNil(t, lo) require.Equal(t, "105", *hi, "P95 of single value is the value") require.Equal(t, "95", *lo) } func TestRangeHighLow_Twenty(t *testing.T) { // highs 1..20、lows 1..20;P95(high)=19.05、P5(low)=1.95 klines := make([]entity.Kline, 20) for i := 0; i < 20; i++ { v := float64(i + 1) klines[i] = mkKline(int64(i), strconv.FormatFloat(v, 'f', -1, 64), strconv.FormatFloat(v, 'f', -1, 64), strconv.FormatFloat(v, 'f', -1, 64), strconv.FormatFloat(v, 'f', -1, 64), ) } hi, lo := rangeHighLow(klines) require.NotNil(t, hi) require.NotNil(t, lo) hiF, _ := parsePrice(*hi) loF, _ := parsePrice(*lo) require.InDelta(t, 19.05, hiF, 1e-6) require.InDelta(t, 1.95, loF, 1e-6) } func TestRangeHighLow_SkipsMalformed(t *testing.T) { klines := []entity.Kline{ mkKline(0, "100", "abc", "99", "100"), // high malformed → skipped mkKline(1, "100", "200", "xyz", "100"), // low malformed → skipped mkKline(2, "100", "150", "50", "100"), // good } hi, lo := rangeHighLow(klines) require.NotNil(t, hi) require.NotNil(t, lo) // 有效 highs = [200, 150],有效 lows = [99, 50] hiF, _ := parsePrice(*hi) loF, _ := parsePrice(*lo) // P95 of {150,200}: sorted=[150,200], rank=0.95 → 150 + 0.95*50 = 197.5 // P5 of {50,99}: rank=0.05 → 50 + 0.05*49 = 52.45 require.InDelta(t, 197.5, hiF, 1e-6) require.InDelta(t, 52.45, loF, 1e-6) } // ---- longShortCrossings ----------------------------------------------- func TestLongShortCrossings_Empty(t *testing.T) { require.Nil(t, longShortCrossings(nil, nil, 20)) require.Nil(t, longShortCrossings([]entity.LongShortRatio{mkLSR(0, "1.5")}, nil, 20)) } func TestLongShortCrossings_AllAbove(t *testing.T) { ls := []entity.LongShortRatio{ mkLSR(0, "1.2"), mkLSR(1, "1.3"), mkLSR(2, "1.5"), } klines := []entity.Kline{ mkKline(0, "100", "101", "99", "100"), mkKline(1, "100", "101", "99", "100"), mkKline(2, "100", "101", "99", "100"), } require.Nil(t, longShortCrossings(ls, klines, 20)) } func TestLongShortCrossings_AllBelow(t *testing.T) { ls := []entity.LongShortRatio{ mkLSR(0, "0.5"), mkLSR(1, "0.7"), mkLSR(2, "0.9"), } klines := []entity.Kline{ mkKline(0, "100", "101", "99", "100"), mkKline(1, "100", "101", "99", "100"), mkKline(2, "100", "101", "99", "100"), } require.Nil(t, longShortCrossings(ls, klines, 20)) } func TestLongShortCrossings_SingleCrossing(t *testing.T) { // LSR:0.9 → 1.1(穿越发生在 index=1) ls := []entity.LongShortRatio{ mkLSR(0, "0.9"), mkLSR(1, "1.1"), } klines := []entity.Kline{ mkKline(0, "100", "101", "99", "100"), mkKline(1, "120", "121", "119", "120"), } got := longShortCrossings(ls, klines, 20) require.NotNil(t, got) // 穿越价位估计:取 ls[1] 时刻 kline 的 (open+close)/2 = 120 v, ok := parsePrice(*got) require.True(t, ok) require.InDelta(t, 120.0, v, 1e-6) } func TestLongShortCrossings_MultipleCrossings(t *testing.T) { // 来回穿越多次:取近 N 次的价位中位数 // 序列:0.9, 1.1, 0.95, 1.05 → 三次穿越(i=1, i=2, i=3) ls := []entity.LongShortRatio{ mkLSR(0, "0.9"), mkLSR(1, "1.1"), mkLSR(2, "0.95"), mkLSR(3, "1.05"), } klines := []entity.Kline{ mkKline(0, "100", "101", "99", "100"), mkKline(1, "110", "111", "109", "110"), // 穿越价 110 mkKline(2, "120", "121", "119", "120"), // 穿越价 120 mkKline(3, "130", "131", "129", "130"), // 穿越价 130 } got := longShortCrossings(ls, klines, 20) require.NotNil(t, got) // 中位数 = 120 v, ok := parsePrice(*got) require.True(t, ok) require.InDelta(t, 120.0, v, 1e-6) } func TestLongShortCrossings_KeepNLimit(t *testing.T) { // 24 个穿越价 1..24,keepN=20 → 保留最近 20 个(价 5..24),中位 14.5 ls := []entity.LongShortRatio{} klines := []entity.Kline{} for i := 0; i < 25; i++ { var r string if i%2 == 0 { r = "0.9" } else { r = "1.1" } ls = append(ls, mkLSR(int64(i), r)) v := float64(i + 1) klines = append(klines, mkKline(int64(i), strconv.FormatFloat(v, 'f', -1, 64), strconv.FormatFloat(v, 'f', -1, 64), strconv.FormatFloat(v, 'f', -1, 64), strconv.FormatFloat(v, 'f', -1, 64), )) } got := longShortCrossings(ls, klines, 20) require.NotNil(t, got) // 24 个穿越(i=1..24),保留最近 20 个对应 klines[5..24] → price 6..25 // 中位 = (15+16)/2 = 15.5 v, ok := parsePrice(*got) require.True(t, ok) require.InDelta(t, 15.5, v, 1e-6) } func TestLongShortCrossings_KeepNZero(t *testing.T) { ls := []entity.LongShortRatio{mkLSR(0, "0.9"), mkLSR(1, "1.1")} klines := []entity.Kline{mkKline(0, "100", "100", "100", "100"), mkKline(1, "110", "110", "110", "110")} require.Nil(t, longShortCrossings(ls, klines, 0)) require.Nil(t, longShortCrossings(ls, klines, -1)) } func TestLongShortCrossings_MalformedRatio(t *testing.T) { // 中间一条 LSR ratio 是 "abc",相邻两次穿越都被跳过 ls := []entity.LongShortRatio{ mkLSR(0, "0.9"), mkLSR(1, "abc"), mkLSR(2, "1.1"), } klines := []entity.Kline{ mkKline(0, "100", "101", "99", "100"), mkKline(1, "110", "111", "109", "110"), mkKline(2, "120", "121", "119", "120"), } require.Nil(t, longShortCrossings(ls, klines, 20)) } func TestLongShortCrossings_KlineParseFailure(t *testing.T) { // 穿越发生,但对应 kline 的 open/close 是 malformed → 该穿越被跳过 ls := []entity.LongShortRatio{ mkLSR(0, "0.9"), mkLSR(1, "1.1"), } klines := []entity.Kline{ mkKline(0, "100", "101", "99", "100"), mkKline(1, "abc", "101", "99", "xyz"), } require.Nil(t, longShortCrossings(ls, klines, 20)) } func TestLongShortCrossings_TimestampBeyondKlines(t *testing.T) { // LSR 时间戳全部小于第一根 kline → nearestKlineIndex 返回 0 const hourMs = int64(3600 * 1000) ls := []entity.LongShortRatio{ {Symbol: "X", Period: "1h", Timestamp: -1000, LongShortRatio: "0.9"}, {Symbol: "X", Period: "1h", Timestamp: -500, LongShortRatio: "1.1"}, } klines := []entity.Kline{ {Symbol: "X", Interval: "1h", OpenTime: 0, CloseTime: hourMs - 1, Open: "100", High: "101", Low: "99", Close: "110", IsClosed: true}, {Symbol: "X", Interval: "1h", OpenTime: hourMs, CloseTime: 2*hourMs - 1, Open: "100", High: "101", Low: "99", Close: "110", IsClosed: true}, } got := longShortCrossings(ls, klines, 20) require.NotNil(t, got) v, _ := parsePrice(*got) require.InDelta(t, 105.0, v, 1e-6, "kline[0] (open+close)/2 = 105") } // nearestKlineIndex 直接覆盖:空、ts 在前、ts 在后、ts 居中 func TestNearestKlineIndex(t *testing.T) { const hourMs = int64(3600 * 1000) require.Equal(t, -1, nearestKlineIndex(nil, 0)) klines := []entity.Kline{ {OpenTime: 0}, {OpenTime: hourMs}, {OpenTime: 2 * hourMs}, } require.Equal(t, 0, nearestKlineIndex(klines, -1000), "before all → first") require.Equal(t, 2, nearestKlineIndex(klines, 10*hourMs), "after all → last") require.Equal(t, 0, nearestKlineIndex(klines, hourMs/2-1), "lo 更近") require.Equal(t, 1, nearestKlineIndex(klines, hourMs/2+1), "hi 更近") require.Equal(t, 0, nearestKlineIndex(klines, hourMs/2), "等距时偏向 lo(<=)") require.Equal(t, 1, nearestKlineIndex(klines, hourMs), "ts 等于某点 → 命中") } // ---- Compute(端到端编排) -------------------------------------------- func TestCompute_EmptyInputs(t *testing.T) { u := NewIndicatorUsecase() out := u.Compute(nil, nil) require.NotNil(t, out.Support) require.NotNil(t, out.Resistance) require.Empty(t, out.Support) require.Empty(t, out.Resistance) require.Nil(t, out.RangeHigh) require.Nil(t, out.RangeLow) require.Nil(t, out.LongShortLine) } func TestCompute_SmokeWithVShape(t *testing.T) { u := NewIndicatorUsecase() // V-shape 21 根,应有 1 个 support、0 个 resistance、有 range klines := make([]entity.Kline, 21) for i := 0; i < 21; i++ { var p float64 if i <= 10 { p = 100 - float64(i)*1.0 } else { p = 90 + float64(i-10)*1.0 } klines[i] = mkKline(int64(i), strconv.FormatFloat(p, 'f', -1, 64), strconv.FormatFloat(p+0.5, 'f', -1, 64), strconv.FormatFloat(p, 'f', -1, 64), strconv.FormatFloat(p+0.25, 'f', -1, 64), ) } out := u.Compute(klines, nil) require.Len(t, out.Support, 1) require.Empty(t, out.Resistance) require.NotNil(t, out.RangeHigh) require.NotNil(t, out.RangeLow) require.Nil(t, out.LongShortLine, "no LSR data → nil") }