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:
dela
2026-05-24 20:31:10 +08:00
parent 6d412b2326
commit f47a151409
2 changed files with 353 additions and 31 deletions

View File

@@ -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 7NumPy 默认rank = (n-1)*pfloor + 小数权重插值。
// type-7NumPy 默认rank = (n-1)*pfloor + 小数权重插值。
// n==0 返回 0n==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 计算 RangeHighP95和 RangeLowP5
// 解析失败的价位被跳过,不污染百分位计算。
// 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
}