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%)。
This commit is contained in:
dela
2026-05-25 10:44:33 +08:00
parent 319e73d91f
commit 21b3078094
6 changed files with 423 additions and 39 deletions

View File

@@ -631,3 +631,169 @@ func TestCompute_IntervalsPopulated(t *testing.T) {
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")
}