Files
cryptoHermes/internal/usecase/indicator_test.go
dela 6d412b2326 feat(indicator): ports + 骨架 + 完整测试 table(红灯)
为什么:先把测试与骨架同时落地,让"红灯先行"作为 commit 3 实现的
靶子。骨架函数全部返回零值,所有断言失败但无编译错误也无 panic。

变更:
- ports.go 新增 IndicatorComputer 接口;接收已 fetch 的 slice、
  避免与 MarketContextUsecase 重复 IO(G1 + G12)
- internal/usecase/indicator.go:常量、pivotPoint、9 个 helper +
  Compute 全部签名就位,实现先返零值
- internal/usecase/indicator_test.go:parsePrice/formatPrice/
  percentile/medianFloat/pivot{Highs,Lows}/clusterLevels/
  rangeHighLow/longShortCrossings/Compute 全部 table-driven
  覆盖(含 V-shape、双顶 0.18%/0.45%、三聚类、N=1/2/20 percentile、
  穿越保留 N、malformed 跳过等)
- go.mod 把 stretchr/testify v1.8.1 从 indirect 提升为 direct
2026-05-24 20:16:48 +08:00

452 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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},
}
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)
}
// ---- 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 highindex 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 个 levelStrength="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..20P95(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) {
// LSR0.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..24keepN=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)
}
// ---- 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")
}