package usecase import ( "strconv" "testing" "cryptoHermes/internal/entity" "github.com/stretchr/testify/require" ) func mkOI(idx int, v string) entity.OpenInterest { return entity.OpenInterest{ Symbol: "BTCUSDT", Period: "1h", Timestamp: int64(idx) * 3600 * 1000, OpenInterest: v, } } func mkLSRAt(idx int, ratio string) entity.LongShortRatio { return entity.LongShortRatio{ Symbol: "BTCUSDT", Period: "1h", RatioType: entity.RatioTypeGlobalAccount, Timestamp: int64(idx) * 3600 * 1000, LongShortRatio: ratio, } } // klinesWithLastClose 构造 n 根 1h K 线,全部 close = baseClose, // 最后一根 close = lastClose(用来精准控制价格方向)。 // 价格趋势靠"末根 vs 末根-lookback 根"判定,中间不影响。 func klinesWithLastClose(n int, baseClose, lastClose float64) []entity.Kline { out := make([]entity.Kline, n) for i := 0; i < n; i++ { c := baseClose if i == n-1 { c = lastClose } out[i] = mkKline(int64(i), strconv.FormatFloat(c, 'f', -1, 64), strconv.FormatFloat(c+0.5, 'f', -1, 64), strconv.FormatFloat(c-0.5, 'f', -1, 64), strconv.FormatFloat(c, 'f', -1, 64), ) } return out } // ---- FundingBias ------------------------------------------------------ func TestFundingBias_Cases(t *testing.T) { tests := []struct { name string rate string want string }{ {"crowded_long", "0.001", "crowded_long"}, {"crowded_short", "-0.001", "crowded_short"}, {"neutral_zero", "0", "neutral"}, {"neutral_small_pos", "0.0001", "neutral"}, {"neutral_small_neg", "-0.0001", "neutral"}, {"boundary_high", "0.0005", "neutral"}, // 严格 > {"boundary_low", "-0.0005", "neutral"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, warn := fundingBias(&entity.FundingRate{FundingRate: tc.rate}) require.Equal(t, tc.want, got) require.Empty(t, warn) }) } } func TestFundingBias_NilAndBadInput(t *testing.T) { got, warn := fundingBias(nil) require.Equal(t, "", got) require.NotEmpty(t, warn) got, warn = fundingBias(&entity.FundingRate{FundingRate: ""}) require.Equal(t, "", got) require.NotEmpty(t, warn) got, warn = fundingBias(&entity.FundingRate{FundingRate: "not-a-number"}) require.Equal(t, "", got) require.NotEmpty(t, warn) } // ---- OISignal --------------------------------------------------------- func makeOIHistory(values []float64) []entity.OpenInterest { out := make([]entity.OpenInterest, len(values)) for i, v := range values { out[i] = mkOI(i, formatPrice(v)) } return out } // 25+ 根 K 线方便 priceDirection 拿到(需要 lookback+1 = 25 根)。 func sufficientKlines(latestClose float64) []entity.Kline { return klinesWithLastClose(25, 100, latestClose) } func TestOISignal_InsufficientHistory(t *testing.T) { values := make([]float64, 23) // 23 < oiMinHistoryPoints(24) for i := range values { values[i] = 1000 } got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100)) require.Equal(t, "", got) require.Contains(t, warn, "OI history insufficient") } func TestOISignal_Stable(t *testing.T) { values := make([]float64, 30) for i := range values { values[i] = 1000 } got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100)) require.Equal(t, "stable", got) require.Empty(t, warn) } func TestOISignal_Deleveraging(t *testing.T) { // 前 25 根 1000,最后 5 根降到 800:OI 在跌,不看价格 values := make([]float64, 30) for i := 0; i < 25; i++ { values[i] = 1000 } for i := 25; i < 30; i++ { values[i] = 800 } got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100)) require.Equal(t, "deleveraging", got) require.Empty(t, warn) } func TestOISignal_LongBuilding_PriceUp(t *testing.T) { // OI 涨 + 价格涨(末根 110 > base 100)→ long_building values := make([]float64, 30) for i := 0; i < 25; i++ { values[i] = 1000 } for i := 25; i < 30; i++ { values[i] = 1200 } got, warn := oiSignal(makeOIHistory(values), sufficientKlines(110)) require.Equal(t, "long_building", got) require.Empty(t, warn) } func TestOISignal_ShortBuilding_PriceDown(t *testing.T) { // OI 涨 + 价格跌(末根 90 < base 100)→ short_building values := make([]float64, 30) for i := 0; i < 25; i++ { values[i] = 1000 } for i := 25; i < 30; i++ { values[i] = 1200 } got, warn := oiSignal(makeOIHistory(values), sufficientKlines(90)) require.Equal(t, "short_building", got) require.Empty(t, warn) } func TestOISignal_OIUp_KlinesInsufficient(t *testing.T) { // OI 涨但价格 K 线只有 10 根,无法判定方向 values := make([]float64, 30) for i := 0; i < 25; i++ { values[i] = 1000 } for i := 25; i < 30; i++ { values[i] = 1200 } got, warn := oiSignal(makeOIHistory(values), klinesWithLastClose(10, 100, 110)) require.Equal(t, "", got) require.Contains(t, warn, "primary klines insufficient") } func TestOISignal_FundingIgnoredForDirection(t *testing.T) { // 关键回归用例:funding 为正但价格下跌 // 旧逻辑会误判 long_building,新逻辑应判 short_building values := make([]float64, 30) for i := 0; i < 25; i++ { values[i] = 1000 } for i := 25; i < 30; i++ { values[i] = 1200 } got, warn := oiSignal(makeOIHistory(values), sufficientKlines(90)) require.Equal(t, "short_building", got) require.Empty(t, warn) } // ---- LSRRegime -------------------------------------------------------- func TestLSRRegime_Cases(t *testing.T) { tests := []struct { name string last string want string }{ {"retail_long_heavy", "1.6", "retail_long_heavy"}, {"retail_short_heavy", "0.5", "retail_short_heavy"}, {"balanced_mid", "1.0", "balanced"}, {"balanced_near_hi", "1.5", "balanced"}, {"balanced_near_lo", "0.67", "balanced"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, warn := lsrRegime([]entity.LongShortRatio{mkLSRAt(0, tc.last)}) require.Equal(t, tc.want, got) require.Empty(t, warn) }) } } func TestLSRRegime_UsesLatest(t *testing.T) { got, warn := lsrRegime([]entity.LongShortRatio{ mkLSRAt(0, "1.6"), mkLSRAt(1, "0.5"), }) require.Equal(t, "retail_short_heavy", got) require.Empty(t, warn) } func TestLSRRegime_Empty(t *testing.T) { got, warn := lsrRegime(nil) require.Equal(t, "", got) require.NotEmpty(t, warn) } // ---- Compute(编排) --------------------------------------------------- func TestCompute_AllDataPresent(t *testing.T) { u := NewDerivativesSignalUsecase() values := make([]float64, 30) for i := 0; i < 25; i++ { values[i] = 1000 } for i := 25; i < 30; i++ { values[i] = 1200 } got, warnings := u.Compute( &entity.FundingRate{FundingRate: "0.001"}, makeOIHistory(values), []entity.LongShortRatio{mkLSRAt(0, "1.6")}, sufficientKlines(110), ) require.NotNil(t, got) require.Equal(t, "crowded_long", got.FundingBias) require.Equal(t, "long_building", got.OISignal) require.Equal(t, "retail_long_heavy", got.LSRRegime) require.Empty(t, warnings) } func TestCompute_AllEmpty_ReturnsNilWithWarnings(t *testing.T) { u := NewDerivativesSignalUsecase() got, warnings := u.Compute(nil, nil, nil, nil) require.Nil(t, got) // 三个子判定都失败,应有 3 条 warning require.Len(t, warnings, 3) } func TestCompute_PartialData(t *testing.T) { u := NewDerivativesSignalUsecase() got, warnings := u.Compute( &entity.FundingRate{FundingRate: "0.001"}, nil, nil, nil, ) require.NotNil(t, got) require.Equal(t, "crowded_long", got.FundingBias) require.Equal(t, "", got.OISignal) require.Equal(t, "", got.LSRRegime) // OI + LSR 缺数据,应有 2 条 warning require.Len(t, warnings, 2) }