From f47a151409cb5c1869e0cbc75e21f47f5c337580 Mon Sep 17 00:00:00 2001 From: dela Date: Sun, 24 May 2026 20:31:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(indicator):=20=E5=A1=AB=E5=AE=9E=20pivot/c?= =?UTF-8?q?luster/percentile/LSR-crossings=20=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 四条)。 --- internal/usecase/indicator.go | 307 ++++++++++++++++++++++++++--- internal/usecase/indicator_test.go | 77 ++++++++ 2 files changed, 353 insertions(+), 31 deletions(-) diff --git a/internal/usecase/indicator.go b/internal/usecase/indicator.go index 854d379..06f0a7e 100644 --- a/internal/usecase/indicator.go +++ b/internal/usecase/indicator.go @@ -13,6 +13,7 @@ package usecase import ( + "math" "sort" "strconv" @@ -55,80 +56,324 @@ type pivotPoint struct { // Compute 是 IndicatorComputer 的唯一对外方法。 // klines 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。 // longShort 同样升序;可能为空。 -// -// 当前实现为骨架(commit 2):返回零值结构,让 indicator_test.go 全红。 -// commit 3 填实算法。 func (u *IndicatorUsecase) Compute( klines []entity.Kline, longShort []entity.LongShortRatio, ) entity.TechnicalStructure { - _ = klines - _ = longShort + highs := pivotHighs(klines, pivotLeft, pivotRight) + lows := pivotLows(klines, pivotLeft, pivotRight) + resistance := clusterLevels(highs, clusterPctThreshold, sourceResistancePivot) + support := clusterLevels(lows, clusterPctThreshold, sourceSupportPivot) + if support == nil { + support = []entity.TechnicalLevel{} + } + if resistance == nil { + resistance = []entity.TechnicalLevel{} + } + hi, lo := rangeHighLow(klines) + lsLine := longShortCrossings(longShort, klines, longShortCrossKeep) return entity.TechnicalStructure{ - Support: []entity.TechnicalLevel{}, - Resistance: []entity.TechnicalLevel{}, + Support: support, + Resistance: resistance, + RangeHigh: hi, + RangeLow: lo, + LongShortLine: lsLine, } } // parsePrice 用 strconv 把 string 价格解析成 float64。 -// 失败(空 / NaN / 非数字)返回 ok=false,调用者应跳过该值不 panic。 +// 失败(空 / 非数字 / 含空格)返回 ok=false,调用者应跳过该值不 panic。 +// NaN 与 ±Inf 也视为解析失败。 func parsePrice(s string) (float64, bool) { - _ = sort.Float64s // keep import; will be used in commit 3 - _ = strconv.Itoa - return 0, false + if s == "" { + return 0, false + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false + } + return f, true } // formatPrice 把 transient float64 转回 string。 // 用 'f' / prec=-1 / bitSize=64 让 FormatFloat 给出最短可往返表示。 func formatPrice(f float64) string { - return "" + return strconv.FormatFloat(f, 'f', -1, 64) } // pivotHighs 在 klines 上扫描,返回所有 left=L、right=R 的 swing high。 -// swing high 条件:klines[i].High 严格大于 [i-L, i-1] 和 [i+1, i+R] 内所有 high。 -// 边界:i < L 或 i > len-R-1 跳过。 +// swing high 条件:klines[i].High 严格大于 [i-L, i-1] 内所有 high, +// 且严格大于 [i+1, i+R] 内所有 high。 +// 价格解析失败的 K 线被当作不参与(即既不能成为 pivot、也不参与邻居比较)。 func pivotHighs(klines []entity.Kline, left, right int) []pivotPoint { - return nil + n := len(klines) + if n < left+right+1 { + return nil + } + highs := make([]float64, n) + ok := make([]bool, n) + for i, k := range klines { + highs[i], ok[i] = parsePrice(k.High) + } + var out []pivotPoint + for i := left; i <= n-right-1; i++ { + if !ok[i] { + continue + } + isPivot := true + for j := i - left; j < i; j++ { + if !ok[j] || highs[j] >= highs[i] { + isPivot = false + break + } + } + if !isPivot { + continue + } + for j := i + 1; j <= i+right; j++ { + if !ok[j] || highs[j] >= highs[i] { + isPivot = false + break + } + } + if isPivot { + out = append(out, pivotPoint{Price: highs[i], Index: i}) + } + } + return out } // pivotLows 对称镜像 pivotHighs,按 .Low 找局部低点。 func pivotLows(klines []entity.Kline, left, right int) []pivotPoint { - return nil + n := len(klines) + if n < left+right+1 { + return nil + } + lows := make([]float64, n) + ok := make([]bool, n) + for i, k := range klines { + lows[i], ok[i] = parsePrice(k.Low) + } + var out []pivotPoint + for i := left; i <= n-right-1; i++ { + if !ok[i] { + continue + } + isPivot := true + for j := i - left; j < i; j++ { + if !ok[j] || lows[j] <= lows[i] { + isPivot = false + break + } + } + if !isPivot { + continue + } + for j := i + 1; j <= i+right; j++ { + if !ok[j] || lows[j] <= lows[i] { + isPivot = false + break + } + } + if isPivot { + out = append(out, pivotPoint{Price: lows[i], Index: i}) + } + } + return out } // clusterLevels 把 pivot 点按价格相对距离聚合到同一 level。 -// 算法:对 points 按价格升序排序;从最小往上扫,若当前点价格与当前 cluster -// 的均价相对距离 < pctThreshold,则合并;否则起新 cluster。 -// 每个 cluster 输出 TechnicalLevel{Price: formatPrice(均价), Strength: strconv.Itoa(len), Source: source}。 -// 输出按 Strength 降序、Price 降序(同 Strength 时高价在前)。 +// 算法:按价格升序排序;扫描时若 (当前价 - cluster 均价) / cluster 均价 < pct +// 则合并;否则开启新 cluster。 +// 输出按 Strength 降序,Strength 相同时按价格降序。 func clusterLevels(points []pivotPoint, pctThreshold float64, source string) []entity.TechnicalLevel { - return nil + if len(points) == 0 { + return nil + } + sorted := make([]pivotPoint, len(points)) + copy(sorted, points) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Price < sorted[j].Price + }) + + type cluster struct { + sum float64 + count int + } + clusters := []cluster{{sum: sorted[0].Price, count: 1}} + for i := 1; i < len(sorted); i++ { + last := &clusters[len(clusters)-1] + mean := last.sum / float64(last.count) + dist := math.Abs(sorted[i].Price-mean) / mean + if dist < pctThreshold { + last.sum += sorted[i].Price + last.count++ + } else { + clusters = append(clusters, cluster{sum: sorted[i].Price, count: 1}) + } + } + + out := make([]entity.TechnicalLevel, len(clusters)) + for i, c := range clusters { + out[i] = entity.TechnicalLevel{ + Price: formatPrice(c.sum / float64(c.count)), + Strength: strconv.Itoa(c.count), + Source: source, + } + } + // 排序:Strength desc,相同则 Price desc + sort.SliceStable(out, func(i, j int) bool { + si, _ := strconv.Atoi(out[i].Strength) + sj, _ := strconv.Atoi(out[j].Strength) + if si != sj { + return si > sj + } + pi, _ := parsePrice(out[i].Price) + pj, _ := parsePrice(out[j].Price) + return pi > pj + }) + return out } // percentile 在升序排好的 sorted 上算线性插值的 p 分位(p∈[0,1])。 -// 经典 type 7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。 +// type-7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。 // n==0 返回 0;n==1 返回该唯一值。 func percentile(sortedAsc []float64, p float64) float64 { - return 0 + n := len(sortedAsc) + if n == 0 { + return 0 + } + if n == 1 { + return sortedAsc[0] + } + if p <= 0 { + return sortedAsc[0] + } + if p >= 1 { + return sortedAsc[n-1] + } + rank := float64(n-1) * p + lo := int(math.Floor(rank)) + hi := lo + 1 + if hi >= n { + return sortedAsc[n-1] + } + weight := rank - float64(lo) + return sortedAsc[lo] + weight*(sortedAsc[hi]-sortedAsc[lo]) } // rangeHighLow 计算 RangeHigh(P95)和 RangeLow(P5)。 // 解析失败的价位被跳过,不污染百分位计算。 -// klines 为空时返回 (nil, nil)。 +// klines 为空(或所有价位都 parse 失败)时返回 (nil, nil)。 func rangeHighLow(klines []entity.Kline) (high *string, low *string) { - return nil, nil + if len(klines) == 0 { + return nil, nil + } + highs := make([]float64, 0, len(klines)) + lows := make([]float64, 0, len(klines)) + for _, k := range klines { + if v, ok := parsePrice(k.High); ok { + highs = append(highs, v) + } + if v, ok := parsePrice(k.Low); ok { + lows = append(lows, v) + } + } + if len(highs) == 0 || len(lows) == 0 { + return nil, nil + } + sort.Float64s(highs) + sort.Float64s(lows) + hi := formatPrice(percentile(highs, rangePercentileHi)) + lo := formatPrice(percentile(lows, rangePercentileLo)) + return &hi, &lo } -// longShortCrossings 在 ls 上扫描 ratio == 1.0 的穿越点。 -// "穿越" 定义:相邻两条 ratio 一上一下穿过 1.0(含相等边界,但单边贴 1.0 不算)。 -// 对每个穿越点,按时间戳找最近的 kline,用 (open+close)/2 作为穿越价位估计。 -// 保留最近 keepN 次穿越的价位,取中位数;无穿越或全部 parse 失败 → nil。 +// longShortCrossings 在 ls 上扫描 ratio 穿越 1.0 的事件。 +// 穿越定义:相邻两条 ratio 一条 <1、另一条 >1(严格不相等),即 (a-1)*(b-1) < 0。 +// 对每个穿越点 i(穿越发生在 ls[i-1] → ls[i] 之间),用 ls[i].Timestamp 找 +// 最近的 kline,用该 kline 的 (open+close)/2 作为穿越价估计。 +// 保留最近 keepN 次穿越,取价位中位数。无穿越或全部 parse 失败 → nil。 func longShortCrossings(ls []entity.LongShortRatio, klines []entity.Kline, keepN int) *string { - return nil + if len(ls) < 2 || len(klines) == 0 || keepN <= 0 { + return nil + } + parsed := make([]float64, len(ls)) + pok := make([]bool, len(ls)) + for i, r := range ls { + parsed[i], pok[i] = parsePrice(r.LongShortRatio) + } + var prices []float64 + for i := 1; i < len(ls); i++ { + if !pok[i-1] || !pok[i] { + continue + } + a, b := parsed[i-1]-1.0, parsed[i]-1.0 + if a*b >= 0 { + continue + } + // 找最近 kline + ts := ls[i].Timestamp + kIdx := nearestKlineIndex(klines, ts) + if kIdx < 0 { + continue + } + op, ok1 := parsePrice(klines[kIdx].Open) + cl, ok2 := parsePrice(klines[kIdx].Close) + if !ok1 || !ok2 { + continue + } + prices = append(prices, (op+cl)/2) + } + if len(prices) == 0 { + return nil + } + if len(prices) > keepN { + prices = prices[len(prices)-keepN:] + } + med := formatPrice(medianFloat(prices)) + return &med +} + +// nearestKlineIndex 在 klines 中找 OpenTime 与 ts 最接近的那根的下标。 +// klines 必须按 OpenTime 升序。空切片返回 -1。 +func nearestKlineIndex(klines []entity.Kline, ts int64) int { + if len(klines) == 0 { + return -1 + } + // 二分找第一个 OpenTime >= ts + idx := sort.Search(len(klines), func(i int) bool { + return klines[i].OpenTime >= ts + }) + switch { + case idx == 0: + return 0 + case idx == len(klines): + return len(klines) - 1 + default: + // 比较 idx 与 idx-1 哪个更近 + diffHi := klines[idx].OpenTime - ts + diffLo := ts - klines[idx-1].OpenTime + if diffLo <= diffHi { + return idx - 1 + } + return idx + } } // medianFloat 计算切片中位数;空切片返回 0。会改写传入 slice(内部 sort)。 // 调用者若不希望被改,应先 copy。 func medianFloat(xs []float64) float64 { - return 0 + n := len(xs) + if n == 0 { + return 0 + } + sort.Float64s(xs) + if n%2 == 1 { + return xs[n/2] + } + return (xs[n/2-1] + xs[n/2]) / 2 } diff --git a/internal/usecase/indicator_test.go b/internal/usecase/indicator_test.go index 0556488..36aae45 100644 --- a/internal/usecase/indicator_test.go +++ b/internal/usecase/indicator_test.go @@ -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) {