- collector.symbols 与 supportedSymbols 加 SOL/BNB/DOGE,env-default 与 README 同步 - entity 追加 TechnicalStructure.Intervals(每周期 Bollinger + Vegas,omitempty 不破坏既有字段)与 DerivativesBundle.Signal - 新增纯解析 usecase derivatives_signal.go:fundingBias 绝对阈值、oiSignal 用 P20 baseline + 价格方向(OI up + 价格 up/down → long/short_building,funding 不参与方向)、lsrRegime 绝对阈值;signal 数据不足走 warnings 进 DataQuality - indicator.go 加 sma/stddev/ema/bollinger/vegas + computeIntervalTechnicals;IndicatorComputer 签名收 klines map - harness 同步:G12 扩入 derivatives_signal.go(含 Makefile G12.5/6 + ADR-0002 适用范围 + project-map / harness-health 更新)
281 lines
7.5 KiB
Go
281 lines
7.5 KiB
Go
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)
|
||
}
|