feat(indicator): 填实 pivot/cluster/percentile/LSR-crossings 算法
- pivotHighs/pivotLows:严格大于/小于左右 5 根邻居才认 swing
- clusterLevels:按价格升序扫一遍,相对距离 <0.3% 合并到当前 cluster;
输出按 Strength desc + Price desc 排序
- percentile:type-7 线性插值(NumPy 默认)
- rangeHighLow:P95(High) / P5(Low),malformed 价位跳过不污染统计
- longShortCrossings:相邻 ratio (a-1)*(b-1)<0 严格穿越;用 ls 时间戳
二分找最近 kline,取 (open+close)/2 作为穿越价位估计;保留近 N 取中位
- nearestKlineIndex:sort.Search 二分 + 邻居比距离;等距偏向 lo
数值边界严守 ADR-0002 / G12:所有 float64 仅在本文件内部,输出到
entity 前 FormatFloat('f', -1, 64) 转回 string。
补足 4 个函数的边界测试(parsePrice NaN/Inf、percentile p<=0/p>=1、
longShortCrossings keepN<=0/ratio malformed/kline parse 失败/
时间戳前置、nearestKlineIndex 全分支)。
覆盖率:indicator.go 12 个函数平均 97.7%,每个 ≥93.8%。
全部 12 条守卫 grep 无输出(含新增 G12 四条)。
This commit is contained in:
@@ -89,6 +89,9 @@ func TestParsePrice(t *testing.T) {
|
||||
{"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) {
|
||||
@@ -128,6 +131,11 @@ func TestPercentile(t *testing.T) {
|
||||
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)
|
||||
// 边界 p:p<=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 ------------------------------------------------------
|
||||
@@ -410,6 +418,75 @@ func TestLongShortCrossings_KeepNLimit(t *testing.T) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user