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:
dela
2026-05-24 23:19:57 +08:00
parent 622ff1d317
commit fa769331c2
16 changed files with 912 additions and 66 deletions

View 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 根降到 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)
}