Files
cryptoHermes/internal/usecase/derivatives_signal_test.go
dela fa769331c2 feat(market): Phase 1 扩展 — 更多 symbol + Bollinger/Vegas + 衍生品信号
- 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 更新)
2026-05-24 23:19:57 +08:00

281 lines
7.5 KiB
Go
Raw Permalink 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"
)
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 根降到 800OI 在跌,不看价格
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)
}