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 更新)
This commit is contained in:
280
internal/usecase/derivatives_signal_test.go
Normal file
280
internal/usecase/derivatives_signal_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user