Files
cryptoHermes/internal/usecase/indicator_test.go
dela 21b3078094 feat(indicator): per-interval S/R + Range + LSLine + Donchian 箱体(ADR-0003)
按 ADR-0003 把 Support / Resistance / Range / LSLine 从顶层下沉进
IntervalTechnicals,每个周期独立计算;顶层 5 字段保留为 Intervals[primary]
镜像(单一来源,不双写)以维持向后兼容的 JSON shape。

新增 BoxBreak(Donchian 风格,lookback=20 根不含当前根,K 线 < 21 → nil),
落到 IntervalTechnicals.Box。

IndicatorComputer.Compute 签名升级到 longShortByInterval map:
market_context 改为并发拉取 15m/1h/4h/1d 的全局 LSR,1w 不在 Binance LSR
支持列表 → 加 warning lsr_unsupported_interval:1w。

测试:boxBreak 6 个 case(inside/break_up/break_down/insufficient/2 个 parse
失败)+ per-interval Support/Resistance/Range 跨周期断言 + 镜像不变量 +
缺 LSR key → LongShortLine nil。indicator.go 平均覆盖 97.26%(boxBreak 100%)。
2026-05-25 10:44:33 +08:00

800 lines
26 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"
)
// ---- 测试辅助 ----------------------------------------------------------
// mkKline 构造一根 1h K 线;只填指标关心的字段。
// openTime 单位毫秒hour 序号映射到 1h 时基。
func mkKline(hourIdx int64, open, high, low, close string) entity.Kline {
const hourMs = int64(3600 * 1000)
return entity.Kline{
Symbol: "BTCUSDT",
Interval: "1h",
OpenTime: hourIdx * hourMs,
CloseTime: hourIdx*hourMs + hourMs - 1,
Open: open,
High: high,
Low: low,
Close: close,
IsClosed: true,
}
}
func mkLSR(hourIdx int64, ratio string) entity.LongShortRatio {
const hourMs = int64(3600 * 1000)
return entity.LongShortRatio{
Symbol: "BTCUSDT",
Period: "1h",
RatioType: entity.RatioTypeGlobalAccount,
Timestamp: hourIdx * hourMs,
LongShortRatio: ratio,
}
}
// 单调上升序列:从 baseHigh 起,每根 K 线 high/low/close 各 +stepUSD。
// 共 n 根hourIdx 从 0 起。
func ascending(n int, baseLow, step float64) []entity.Kline {
out := make([]entity.Kline, n)
for i := 0; i < n; i++ {
lo := baseLow + float64(i)*step
hi := lo + step
mid := (lo + hi) / 2
out[i] = mkKline(int64(i),
strconv.FormatFloat(lo, 'f', -1, 64),
strconv.FormatFloat(hi, 'f', -1, 64),
strconv.FormatFloat(lo, 'f', -1, 64),
strconv.FormatFloat(mid, 'f', -1, 64),
)
}
return out
}
func descending(n int, baseHigh, step float64) []entity.Kline {
out := make([]entity.Kline, n)
for i := 0; i < n; i++ {
hi := baseHigh - float64(i)*step
lo := hi - step
mid := (lo + hi) / 2
out[i] = mkKline(int64(i),
strconv.FormatFloat(hi, 'f', -1, 64),
strconv.FormatFloat(hi, 'f', -1, 64),
strconv.FormatFloat(lo, 'f', -1, 64),
strconv.FormatFloat(mid, 'f', -1, 64),
)
}
return out
}
// ---- parsePrice -------------------------------------------------------
func TestParsePrice(t *testing.T) {
cases := []struct {
name string
in string
want float64
ok bool
}{
{"normal", "108000.12", 108000.12, true},
{"zero", "0", 0, true},
{"negative", "-1.5", -1.5, true},
{"scientific", "1e3", 1000, true},
{"empty", "", 0, false},
{"non-numeric", "abc", 0, false},
{"whitespace", " 1.0 ", 0, false},
{"nan", "NaN", 0, false},
{"posinf", "Inf", 0, false},
{"neginf", "-Inf", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := parsePrice(c.in)
require.Equal(t, c.ok, ok, "ok mismatch for %q", c.in)
if c.ok {
require.InDelta(t, c.want, got, 1e-9)
}
})
}
}
// ---- formatPrice ------------------------------------------------------
func TestFormatPrice(t *testing.T) {
require.Equal(t, "0", formatPrice(0))
require.Equal(t, "1.5", formatPrice(1.5))
require.Equal(t, "108000.12", formatPrice(108000.12))
require.Equal(t, "-3", formatPrice(-3))
}
// ---- percentile -------------------------------------------------------
func TestPercentile(t *testing.T) {
// type-7 linear interpolation (NumPy default)
require.Equal(t, 0.0, percentile(nil, 0.5), "empty → 0")
require.Equal(t, 42.0, percentile([]float64{42}, 0.95), "N=1")
// N=2: P95: rank=(2-1)*0.95=0.95, between [0]=10 and [1]=20 → 10 + 0.95*10 = 19.5
require.InDelta(t, 19.5, percentile([]float64{10, 20}, 0.95), 1e-9)
require.InDelta(t, 10.5, percentile([]float64{10, 20}, 0.05), 1e-9)
// N=20: rank=19*0.95=18.05, between [18] and [19]
xs := make([]float64, 20)
for i := range xs {
xs[i] = float64(i + 1) // 1..20
}
// rank=18.05 → 19 + 0.05*(20-19) = 19.05
require.InDelta(t, 19.05, percentile(xs, 0.95), 1e-9)
// rank=0.95 → 1 + 0.95*(2-1) = 1.95
require.InDelta(t, 1.95, percentile(xs, 0.05), 1e-9)
// 边界 pp<=0 → 第一个元素p>=1 → 最后元素
require.Equal(t, 1.0, percentile(xs, 0.0))
require.Equal(t, 1.0, percentile(xs, -0.5))
require.Equal(t, 20.0, percentile(xs, 1.0))
require.Equal(t, 20.0, percentile(xs, 1.5))
}
// ---- medianFloat ------------------------------------------------------
func TestMedianFloat(t *testing.T) {
require.Equal(t, 0.0, medianFloat(nil))
require.Equal(t, 5.0, medianFloat([]float64{5}))
require.InDelta(t, 7.5, medianFloat([]float64{5, 10}), 1e-9)
require.InDelta(t, 5.0, medianFloat([]float64{1, 5, 9}), 1e-9)
require.InDelta(t, 4.0, medianFloat([]float64{9, 1, 5, 3}), 1e-9) // unsorted input
}
// ---- pivotHighs / pivotLows ------------------------------------------
func TestPivot_EmptyAndShortInputs(t *testing.T) {
require.Empty(t, pivotHighs(nil, 5, 5))
require.Empty(t, pivotLows(nil, 5, 5))
require.Empty(t, pivotHighs(ascending(5, 100, 1), 5, 5), "N < 2L+1")
require.Empty(t, pivotLows(ascending(10, 100, 1), 5, 5), "N < 2L+1")
}
func TestPivot_MonotonicNoPivots(t *testing.T) {
asc := ascending(30, 100, 10)
require.Empty(t, pivotHighs(asc, 5, 5), "ascending has no swing high in interior")
require.Empty(t, pivotLows(asc, 5, 5), "ascending has no swing low in interior")
desc := descending(30, 100, 1)
require.Empty(t, pivotHighs(desc, 5, 5))
require.Empty(t, pivotLows(desc, 5, 5))
}
func TestPivot_VShape(t *testing.T) {
// 构造 V 形:前 10 根 high 递减、后 10 根递增;中间最低点应为唯一 pivotLow
klines := make([]entity.Kline, 21)
for i := 0; i < 21; i++ {
// price 形态100 -> 90 (index 10) -> 100
var p float64
if i <= 10 {
p = 100 - float64(i)*1.0
} else {
p = 90 + float64(i-10)*1.0
}
hi := p + 0.5
lo := p
mid := p + 0.25
klines[i] = mkKline(int64(i),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(hi, 'f', -1, 64),
strconv.FormatFloat(lo, 'f', -1, 64),
strconv.FormatFloat(mid, 'f', -1, 64),
)
}
highs := pivotHighs(klines, 5, 5)
lows := pivotLows(klines, 5, 5)
require.Empty(t, highs, "V-shape has no pivot high")
require.Len(t, lows, 1, "V-shape has exactly one pivot low")
require.Equal(t, 10, lows[0].Index)
require.InDelta(t, 90.0, lows[0].Price, 1e-9)
}
func TestPivot_DoubleTop(t *testing.T) {
// 两个明确的 pivot highindex 7 和 index 15
// 用价格谷-峰-谷-峰-谷 结构
klines := make([]entity.Kline, 23)
// 默认平的低位 100
for i := range klines {
klines[i] = mkKline(int64(i), "100", "100.5", "99.5", "100")
}
// peak1 at i=7, price 110
klines[7] = mkKline(7, "109", "110", "108", "109.5")
// peak2 at i=15, price 110.2 (0.18% 距离,<0.3% 阈值)
klines[15] = mkKline(15, "109", "110.2", "108", "109.5")
highs := pivotHighs(klines, 5, 5)
require.Len(t, highs, 2)
require.Equal(t, 7, highs[0].Index)
require.Equal(t, 15, highs[1].Index)
}
// ---- clusterLevels ----------------------------------------------------
func TestClusterLevels_Empty(t *testing.T) {
require.Empty(t, clusterLevels(nil, 0.003, sourceResistancePivot))
}
func TestClusterLevels_DoubleTopWithinThreshold(t *testing.T) {
// 两个点相差 0.18%< 0.3%)→ 合并为 1 个 levelStrength="2"
pts := []pivotPoint{
{Price: 110.0, Index: 7},
{Price: 110.2, Index: 15},
}
out := clusterLevels(pts, 0.003, sourceResistancePivot)
require.Len(t, out, 1)
require.Equal(t, "2", out[0].Strength)
require.Equal(t, sourceResistancePivot, out[0].Source)
// 均价 = 110.1
p, ok := parsePrice(out[0].Price)
require.True(t, ok)
require.InDelta(t, 110.1, p, 1e-6)
}
func TestClusterLevels_DoubleTopOutsideThreshold(t *testing.T) {
// 两个点相差 0.45%> 0.3%)→ 不合并
pts := []pivotPoint{
{Price: 110.0, Index: 7},
{Price: 110.5, Index: 15},
}
out := clusterLevels(pts, 0.003, sourceResistancePivot)
require.Len(t, out, 2)
require.Equal(t, "1", out[0].Strength)
require.Equal(t, "1", out[1].Strength)
}
func TestClusterLevels_TripleCluster(t *testing.T) {
pts := []pivotPoint{
{Price: 100.0},
{Price: 100.15},
{Price: 100.25},
}
// 100/100.25 相差 0.25%,都在阈值内
out := clusterLevels(pts, 0.003, sourceResistancePivot)
require.Len(t, out, 1)
require.Equal(t, "3", out[0].Strength)
}
// ---- rangeHighLow -----------------------------------------------------
func TestRangeHighLow_Empty(t *testing.T) {
hi, lo := rangeHighLow(nil)
require.Nil(t, hi)
require.Nil(t, lo)
}
func TestRangeHighLow_SingleCandle(t *testing.T) {
k := []entity.Kline{mkKline(0, "100", "105", "95", "102")}
hi, lo := rangeHighLow(k)
require.NotNil(t, hi)
require.NotNil(t, lo)
require.Equal(t, "105", *hi, "P95 of single value is the value")
require.Equal(t, "95", *lo)
}
func TestRangeHighLow_Twenty(t *testing.T) {
// highs 1..20、lows 1..20P95(high)=19.05、P5(low)=1.95
klines := make([]entity.Kline, 20)
for i := 0; i < 20; i++ {
v := float64(i + 1)
klines[i] = mkKline(int64(i),
strconv.FormatFloat(v, 'f', -1, 64),
strconv.FormatFloat(v, 'f', -1, 64),
strconv.FormatFloat(v, 'f', -1, 64),
strconv.FormatFloat(v, 'f', -1, 64),
)
}
hi, lo := rangeHighLow(klines)
require.NotNil(t, hi)
require.NotNil(t, lo)
hiF, _ := parsePrice(*hi)
loF, _ := parsePrice(*lo)
require.InDelta(t, 19.05, hiF, 1e-6)
require.InDelta(t, 1.95, loF, 1e-6)
}
func TestRangeHighLow_SkipsMalformed(t *testing.T) {
klines := []entity.Kline{
mkKline(0, "100", "abc", "99", "100"), // high malformed → skipped
mkKline(1, "100", "200", "xyz", "100"), // low malformed → skipped
mkKline(2, "100", "150", "50", "100"), // good
}
hi, lo := rangeHighLow(klines)
require.NotNil(t, hi)
require.NotNil(t, lo)
// 有效 highs = [200, 150],有效 lows = [99, 50]
hiF, _ := parsePrice(*hi)
loF, _ := parsePrice(*lo)
// P95 of {150,200}: sorted=[150,200], rank=0.95 → 150 + 0.95*50 = 197.5
// P5 of {50,99}: rank=0.05 → 50 + 0.05*49 = 52.45
require.InDelta(t, 197.5, hiF, 1e-6)
require.InDelta(t, 52.45, loF, 1e-6)
}
// ---- longShortCrossings -----------------------------------------------
func TestLongShortCrossings_Empty(t *testing.T) {
require.Nil(t, longShortCrossings(nil, nil, 20))
require.Nil(t, longShortCrossings([]entity.LongShortRatio{mkLSR(0, "1.5")}, nil, 20))
}
func TestLongShortCrossings_AllAbove(t *testing.T) {
ls := []entity.LongShortRatio{
mkLSR(0, "1.2"), mkLSR(1, "1.3"), mkLSR(2, "1.5"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "100", "101", "99", "100"),
mkKline(2, "100", "101", "99", "100"),
}
require.Nil(t, longShortCrossings(ls, klines, 20))
}
func TestLongShortCrossings_AllBelow(t *testing.T) {
ls := []entity.LongShortRatio{
mkLSR(0, "0.5"), mkLSR(1, "0.7"), mkLSR(2, "0.9"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "100", "101", "99", "100"),
mkKline(2, "100", "101", "99", "100"),
}
require.Nil(t, longShortCrossings(ls, klines, 20))
}
func TestLongShortCrossings_SingleCrossing(t *testing.T) {
// LSR0.9 → 1.1(穿越发生在 index=1
ls := []entity.LongShortRatio{
mkLSR(0, "0.9"),
mkLSR(1, "1.1"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "120", "121", "119", "120"),
}
got := longShortCrossings(ls, klines, 20)
require.NotNil(t, got)
// 穿越价位估计:取 ls[1] 时刻 kline 的 (open+close)/2 = 120
v, ok := parsePrice(*got)
require.True(t, ok)
require.InDelta(t, 120.0, v, 1e-6)
}
func TestLongShortCrossings_MultipleCrossings(t *testing.T) {
// 来回穿越多次:取近 N 次的价位中位数
// 序列0.9, 1.1, 0.95, 1.05 → 三次穿越i=1, i=2, i=3
ls := []entity.LongShortRatio{
mkLSR(0, "0.9"),
mkLSR(1, "1.1"),
mkLSR(2, "0.95"),
mkLSR(3, "1.05"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "110", "111", "109", "110"), // 穿越价 110
mkKline(2, "120", "121", "119", "120"), // 穿越价 120
mkKline(3, "130", "131", "129", "130"), // 穿越价 130
}
got := longShortCrossings(ls, klines, 20)
require.NotNil(t, got)
// 中位数 = 120
v, ok := parsePrice(*got)
require.True(t, ok)
require.InDelta(t, 120.0, v, 1e-6)
}
func TestLongShortCrossings_KeepNLimit(t *testing.T) {
// 24 个穿越价 1..24keepN=20 → 保留最近 20 个(价 5..24),中位 14.5
ls := []entity.LongShortRatio{}
klines := []entity.Kline{}
for i := 0; i < 25; i++ {
var r string
if i%2 == 0 {
r = "0.9"
} else {
r = "1.1"
}
ls = append(ls, mkLSR(int64(i), r))
v := float64(i + 1)
klines = append(klines, mkKline(int64(i),
strconv.FormatFloat(v, 'f', -1, 64),
strconv.FormatFloat(v, 'f', -1, 64),
strconv.FormatFloat(v, 'f', -1, 64),
strconv.FormatFloat(v, 'f', -1, 64),
))
}
got := longShortCrossings(ls, klines, 20)
require.NotNil(t, got)
// 24 个穿越i=1..24),保留最近 20 个对应 klines[5..24] → price 6..25
// 中位 = (15+16)/2 = 15.5
v, ok := parsePrice(*got)
require.True(t, ok)
require.InDelta(t, 15.5, v, 1e-6)
}
func TestLongShortCrossings_KeepNZero(t *testing.T) {
ls := []entity.LongShortRatio{mkLSR(0, "0.9"), mkLSR(1, "1.1")}
klines := []entity.Kline{mkKline(0, "100", "100", "100", "100"), mkKline(1, "110", "110", "110", "110")}
require.Nil(t, longShortCrossings(ls, klines, 0))
require.Nil(t, longShortCrossings(ls, klines, -1))
}
func TestLongShortCrossings_MalformedRatio(t *testing.T) {
// 中间一条 LSR ratio 是 "abc",相邻两次穿越都被跳过
ls := []entity.LongShortRatio{
mkLSR(0, "0.9"),
mkLSR(1, "abc"),
mkLSR(2, "1.1"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "110", "111", "109", "110"),
mkKline(2, "120", "121", "119", "120"),
}
require.Nil(t, longShortCrossings(ls, klines, 20))
}
func TestLongShortCrossings_KlineParseFailure(t *testing.T) {
// 穿越发生,但对应 kline 的 open/close 是 malformed → 该穿越被跳过
ls := []entity.LongShortRatio{
mkLSR(0, "0.9"),
mkLSR(1, "1.1"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "abc", "101", "99", "xyz"),
}
require.Nil(t, longShortCrossings(ls, klines, 20))
}
func TestLongShortCrossings_TimestampBeyondKlines(t *testing.T) {
// LSR 时间戳全部小于第一根 kline → nearestKlineIndex 返回 0
const hourMs = int64(3600 * 1000)
ls := []entity.LongShortRatio{
{Symbol: "X", Period: "1h", Timestamp: -1000, LongShortRatio: "0.9"},
{Symbol: "X", Period: "1h", Timestamp: -500, LongShortRatio: "1.1"},
}
klines := []entity.Kline{
{Symbol: "X", Interval: "1h", OpenTime: 0, CloseTime: hourMs - 1, Open: "100", High: "101", Low: "99", Close: "110", IsClosed: true},
{Symbol: "X", Interval: "1h", OpenTime: hourMs, CloseTime: 2*hourMs - 1, Open: "100", High: "101", Low: "99", Close: "110", IsClosed: true},
}
got := longShortCrossings(ls, klines, 20)
require.NotNil(t, got)
v, _ := parsePrice(*got)
require.InDelta(t, 105.0, v, 1e-6, "kline[0] (open+close)/2 = 105")
}
// nearestKlineIndex 直接覆盖空、ts 在前、ts 在后、ts 居中
func TestNearestKlineIndex(t *testing.T) {
const hourMs = int64(3600 * 1000)
require.Equal(t, -1, nearestKlineIndex(nil, 0))
klines := []entity.Kline{
{OpenTime: 0},
{OpenTime: hourMs},
{OpenTime: 2 * hourMs},
}
require.Equal(t, 0, nearestKlineIndex(klines, -1000), "before all → first")
require.Equal(t, 2, nearestKlineIndex(klines, 10*hourMs), "after all → last")
require.Equal(t, 0, nearestKlineIndex(klines, hourMs/2-1), "lo 更近")
require.Equal(t, 1, nearestKlineIndex(klines, hourMs/2+1), "hi 更近")
require.Equal(t, 0, nearestKlineIndex(klines, hourMs/2), "等距时偏向 lo<=")
require.Equal(t, 1, nearestKlineIndex(klines, hourMs), "ts 等于某点 → 命中")
}
// ---- Compute端到端编排 --------------------------------------------
func TestCompute_EmptyInputs(t *testing.T) {
u := NewIndicatorUsecase()
out := u.Compute(nil, "1h", nil)
require.NotNil(t, out.Support)
require.NotNil(t, out.Resistance)
require.Empty(t, out.Support)
require.Empty(t, out.Resistance)
require.Nil(t, out.RangeHigh)
require.Nil(t, out.RangeLow)
require.Nil(t, out.LongShortLine)
require.NotNil(t, out.Intervals)
require.Empty(t, out.Intervals)
}
func TestCompute_SmokeWithVShape(t *testing.T) {
u := NewIndicatorUsecase()
// V-shape 21 根,应有 1 个 support、0 个 resistance、有 range
klines := make([]entity.Kline, 21)
for i := 0; i < 21; i++ {
var p float64
if i <= 10 {
p = 100 - float64(i)*1.0
} else {
p = 90 + float64(i-10)*1.0
}
klines[i] = mkKline(int64(i),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(p+0.5, 'f', -1, 64),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(p+0.25, 'f', -1, 64),
)
}
out := u.Compute(map[string][]entity.Kline{"1h": klines}, "1h", nil)
require.Len(t, out.Support, 1)
require.Empty(t, out.Resistance)
require.NotNil(t, out.RangeHigh)
require.NotNil(t, out.RangeLow)
require.Nil(t, out.LongShortLine, "no LSR data → nil")
require.Contains(t, out.Intervals, "1h")
}
// ---- Bollinger / Vegas -------------------------------------------------
// flatCloses 生成 n 根价位恒为 v 的 closes用于 Squeeze=tight 验证)。
func flatCloses(n int, v float64) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = v
}
return out
}
// ascCloses 生成等差递增 closesbase, base+step, ...
func ascCloses(n int, base, step float64) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = base + float64(i)*step
}
return out
}
func TestBollinger_FlatSeries_Tight(t *testing.T) {
bb := bollinger(flatCloses(20, 100))
require.NotNil(t, bb)
require.Equal(t, "100", bb.Mid)
require.Equal(t, "100", bb.Upper)
require.Equal(t, "100", bb.Lower)
require.Equal(t, "0", bb.BandwidthPct)
require.Equal(t, "tight", bb.Squeeze)
}
func TestBollinger_AscendingSeries_Expanded(t *testing.T) {
// 0..29 等差,末 20 根 mid=19.5、σ ≈ 5.77、bw ≈ 118%
bb := bollinger(ascCloses(30, 0, 1))
require.NotNil(t, bb)
require.Equal(t, "expanded", bb.Squeeze)
mid, _ := strconv.ParseFloat(bb.Mid, 64)
require.InDelta(t, 19.5, mid, 1e-9)
}
func TestBollinger_InsufficientData(t *testing.T) {
require.Nil(t, bollinger(flatCloses(19, 100)))
require.Nil(t, bollinger(nil))
}
func TestVegas_BullishStack(t *testing.T) {
// 上升序列 → EMA12 > EMA144 > EMA169趋势 bull
v := vegas(ascCloses(200, 100, 1))
require.NotNil(t, v)
e12, _ := strconv.ParseFloat(v.EMA12, 64)
e144, _ := strconv.ParseFloat(v.EMA144, 64)
e169, _ := strconv.ParseFloat(v.EMA169, 64)
require.Greater(t, e12, e144)
require.Greater(t, e144, e169)
require.Equal(t, "bull", v.Trend)
}
func TestVegas_BearishStack(t *testing.T) {
// 下降序列
v := vegas(ascCloses(200, 300, -1))
require.NotNil(t, v)
require.Equal(t, "bear", v.Trend)
}
func TestVegas_FlatRange(t *testing.T) {
// 平盘:三条线相等
v := vegas(flatCloses(200, 100))
require.NotNil(t, v)
require.Equal(t, "range", v.Trend)
}
func TestVegas_InsufficientData(t *testing.T) {
require.Nil(t, vegas(ascCloses(168, 100, 1)))
}
func TestCompute_IntervalsPopulated(t *testing.T) {
u := NewIndicatorUsecase()
// 给 1h25 根) 与 4h200 根) 喂上升序列
mk := func(n int) []entity.Kline {
out := make([]entity.Kline, n)
for i := 0; i < n; i++ {
p := 100 + float64(i)
out[i] = mkKline(int64(i),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(p+0.5, 'f', -1, 64),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(p+0.25, 'f', -1, 64),
)
}
return out
}
out := u.Compute(map[string][]entity.Kline{
"1h": mk(25),
"4h": mk(200),
}, "1h", nil)
require.Contains(t, out.Intervals, "1h")
require.Contains(t, out.Intervals, "4h")
require.NotNil(t, out.Intervals["1h"].Bollinger, "25 closes 够算 BB")
require.Nil(t, out.Intervals["1h"].Vegas, "25 closes 不够算 EMA169")
require.NotNil(t, out.Intervals["4h"].Bollinger)
require.NotNil(t, out.Intervals["4h"].Vegas)
}
// ---- boxBreak ---------------------------------------------------------
// mkFlatKline 生成单根 K 线all 价位相同(用于构造箱体内 baseline
func mkFlatKline(idx int64, price float64) entity.Kline {
s := strconv.FormatFloat(price, 'f', -1, 64)
return mkKline(idx, s, s, s, s)
}
func TestBoxBreak_Inside(t *testing.T) {
// 21 根:前 20 根 high=110、low=90 → box [90,110];最后一根 close=100 → inside
klines := make([]entity.Kline, 21)
for i := 0; i < 20; i++ {
klines[i] = mkKline(int64(i), "100", "110", "90", "100")
}
klines[20] = mkKline(20, "100", "101", "99", "100")
got := boxBreak(klines, boxLookback)
require.NotNil(t, got)
require.Equal(t, "110", got.BoxHigh)
require.Equal(t, "90", got.BoxLow)
require.Equal(t, "inside", got.Status)
require.Equal(t, "20", got.LookbackBars)
}
func TestBoxBreak_BreakUp(t *testing.T) {
// 前 20 根 [90,110];最后一根 close=120 > 110 → break_up
klines := make([]entity.Kline, 21)
for i := 0; i < 20; i++ {
klines[i] = mkKline(int64(i), "100", "110", "90", "100")
}
klines[20] = mkKline(20, "115", "121", "114", "120")
got := boxBreak(klines, boxLookback)
require.NotNil(t, got)
require.Equal(t, "break_up", got.Status)
// 当前根不参与箱体计算:边界仍是 110/90
require.Equal(t, "110", got.BoxHigh)
require.Equal(t, "90", got.BoxLow)
}
func TestBoxBreak_BreakDown(t *testing.T) {
klines := make([]entity.Kline, 21)
for i := 0; i < 20; i++ {
klines[i] = mkKline(int64(i), "100", "110", "90", "100")
}
klines[20] = mkKline(20, "92", "93", "85", "85")
got := boxBreak(klines, boxLookback)
require.NotNil(t, got)
require.Equal(t, "break_down", got.Status)
require.Equal(t, "90", got.BoxLow)
}
func TestBoxBreak_InsufficientData(t *testing.T) {
// 仅 20 根(< lookback+1→ nil
klines := make([]entity.Kline, 20)
for i := 0; i < 20; i++ {
klines[i] = mkFlatKline(int64(i), 100)
}
require.Nil(t, boxBreak(klines, boxLookback))
require.Nil(t, boxBreak(nil, boxLookback), "nil 输入")
require.Nil(t, boxBreak(klines, 0), "lookback=0")
require.Nil(t, boxBreak(klines, -1), "negative lookback")
}
func TestBoxBreak_MalformedPrice(t *testing.T) {
// 窗口内任意根价格 parse 失败 → 整体返回 nil
klines := make([]entity.Kline, 21)
for i := 0; i < 20; i++ {
klines[i] = mkKline(int64(i), "100", "110", "90", "100")
}
klines[5] = mkKline(5, "100", "abc", "90", "100") // bad high
klines[20] = mkKline(20, "100", "101", "99", "100")
require.Nil(t, boxBreak(klines, boxLookback))
}
func TestBoxBreak_MalformedClose(t *testing.T) {
// 当前根 close parse 失败 → nil
klines := make([]entity.Kline, 21)
for i := 0; i < 20; i++ {
klines[i] = mkKline(int64(i), "100", "110", "90", "100")
}
klines[20] = mkKline(20, "100", "101", "99", "xyz")
require.Nil(t, boxBreak(klines, boxLookback))
}
// ---- Compute per-intervalADR-0003 镜像不变量)-------------------------
// TestCompute_PerIntervalSRAndMirror 是 ADR-0003 的核心守卫:
// (a) 每个有足够 kline 的周期都独立产出 Support/Resistance/Range
// (b) 顶层 5 字段必须 == Intervals[primaryInterval] 的对应字段(单一来源镜像)。
// 同时覆盖:缺 LSR key 的周期 LongShortLine 为 nil对应 1w warning 的算法侧契约)。
func TestCompute_PerIntervalSRAndMirror(t *testing.T) {
// V-shape 21 根1 个 pivotLow、0 个 pivotHigh
vshape := func() []entity.Kline {
out := make([]entity.Kline, 21)
for i := 0; i < 21; i++ {
var p float64
if i <= 10 {
p = 100 - float64(i)*1.0
} else {
p = 90 + float64(i-10)*1.0
}
out[i] = mkKline(int64(i),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(p+0.5, 'f', -1, 64),
strconv.FormatFloat(p, 'f', -1, 64),
strconv.FormatFloat(p+0.25, 'f', -1, 64),
)
}
return out
}
// 4h 周期带 LSR 穿越0.9 → 1.1
lsr4h := []entity.LongShortRatio{mkLSR(0, "0.9"), mkLSR(1, "1.1")}
u := NewIndicatorUsecase()
out := u.Compute(
map[string][]entity.Kline{
"1h": vshape(),
"4h": vshape(),
"1w": vshape(), // 1w 无 LSR模拟 Binance 不支持)
},
"4h",
map[string][]entity.LongShortRatio{
"1h": {mkLSR(0, "0.5"), mkLSR(1, "0.7")}, // 全在 1 下方 → 无穿越
"4h": lsr4h,
// 故意不放 "1w"
},
)
// (a) per-interval Support 非空
require.NotEmpty(t, out.Intervals["1h"].Support, "1h V-shape 必有 pivot low")
require.NotEmpty(t, out.Intervals["4h"].Support)
require.NotEmpty(t, out.Intervals["1w"].Support)
require.NotNil(t, out.Intervals["4h"].RangeHigh)
require.NotNil(t, out.Intervals["4h"].RangeLow)
// LongShortLine4h 有穿越 → 非 nil1h 无穿越 → nil1w 缺 key → nil
require.NotNil(t, out.Intervals["4h"].LongShortLine, "4h 有穿越")
require.Nil(t, out.Intervals["1h"].LongShortLine, "1h LSR 不穿越 1.0")
require.Nil(t, out.Intervals["1w"].LongShortLine, "1w 缺 LSR key → nil")
// (b) 顶层镜像 primary=4hADR-0003 单一来源不变量)
// 注意:顶层把 nil 归一为空切片v2.0 JSON shape所以用 ElementsMatch
// 而不是严格 Equal 来表达"语义同源"。RangeHigh/Low/LongShortLine 是 *string
// 共享同一指针,直接 Equal 即可。
require.ElementsMatch(t, out.Intervals["4h"].Support, out.Support, "顶层 Support 与 Intervals[primary] 同源")
require.ElementsMatch(t, out.Intervals["4h"].Resistance, out.Resistance)
require.Equal(t, out.Intervals["4h"].RangeHigh, out.RangeHigh)
require.Equal(t, out.Intervals["4h"].RangeLow, out.RangeLow)
require.Equal(t, out.Intervals["4h"].LongShortLine, out.LongShortLine)
}
// TestCompute_MissingPrimaryGivesEmptySkeletonprimary 不在 Intervals 时,
// 顶层 Support/Resistance 必须是空切片(非 nil向后兼容 v2.0 JSON shape
func TestCompute_MissingPrimaryGivesEmptySkeleton(t *testing.T) {
u := NewIndicatorUsecase()
out := u.Compute(
map[string][]entity.Kline{"1h": ascending(5, 100, 1)},
"4h", // primary 不存在
nil,
)
require.NotNil(t, out.Support)
require.NotNil(t, out.Resistance)
require.Empty(t, out.Support)
require.Empty(t, out.Resistance)
require.Nil(t, out.RangeHigh, "primary 缺席 → 顶层 RangeHigh 也为 nil")
}