// Package usecase: indicator.go 实现技术指标计算(Milestone 6)。 // // 范围(v2 首版): // - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类 // - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值 // - LongShortLine:近 N 根 LSR 穿越 1.0 的价位中位数 // // 数值边界:本文件内部允许 transient float64,但输出到 entity 前 // 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md // 与守卫 G12。 // // 算法参数固定为常量;不读取 config——Milestone 6 不引入参数面。 package usecase import ( "math" "sort" "strconv" "cryptoHermes/internal/entity" ) const ( // Pivot 窗口:左右各 5 根 K 线 pivotLeft = 5 pivotRight = 5 // 价格聚类阈值:两个 pivot 价位相对距离 < 0.3% → 视为同一 level clusterPctThreshold = 0.003 // LongShortLine 保留近 N 次穿越 longShortCrossKeep = 20 // Range 百分位 rangePercentileHi = 0.95 rangePercentileLo = 0.05 // TechnicalLevel.Source 值 sourceSupportPivot = "pivot_low" sourceResistancePivot = "pivot_high" ) // IndicatorUsecase 实现 IndicatorComputer 接口。 // 零状态结构体——所有方法纯函数。 type IndicatorUsecase struct{} // NewIndicatorUsecase 构造器;无依赖。 func NewIndicatorUsecase() *IndicatorUsecase { return &IndicatorUsecase{} } // pivotPoint 是 pivot 检测的内部中间结构,价格已经被 parse 成 float64。 type pivotPoint struct { Price float64 Index int } // Compute 是 IndicatorComputer 的唯一对外方法。 // klines 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。 // longShort 同样升序;可能为空。 func (u *IndicatorUsecase) Compute( klines []entity.Kline, longShort []entity.LongShortRatio, ) entity.TechnicalStructure { 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: support, Resistance: resistance, RangeHigh: hi, RangeLow: lo, LongShortLine: lsLine, } } // parsePrice 用 strconv 把 string 价格解析成 float64。 // 失败(空 / 非数字 / 含空格)返回 ok=false,调用者应跳过该值不 panic。 // NaN 与 ±Inf 也视为解析失败。 func parsePrice(s string) (float64, bool) { 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 strconv.FormatFloat(f, 'f', -1, 64) } // pivotHighs 在 klines 上扫描,返回所有 left=L、right=R 的 swing high。 // 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 { 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 { 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。 // 算法:按价格升序排序;扫描时若 (当前价 - cluster 均价) / cluster 均价 < pct // 则合并;否则开启新 cluster。 // 输出按 Strength 降序,Strength 相同时按价格降序。 func clusterLevels(points []pivotPoint, pctThreshold float64, source string) []entity.TechnicalLevel { 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 + 小数权重插值。 // n==0 返回 0;n==1 返回该唯一值。 func percentile(sortedAsc []float64, p float64) float64 { 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 为空(或所有价位都 parse 失败)时返回 (nil, nil)。 func rangeHighLow(klines []entity.Kline) (high *string, low *string) { 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、另一条 >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 { 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 { 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 }