// Package usecase: indicator.go 实现技术指标计算。 // // 范围(v2.x):每个周期独立产出一组 IntervalTechnicals,包含 // - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类 // - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值 // - LongShortLine:同周期 LSR 穿越 1.0 的价位中位数(LSR 不可用的周期为 nil) // - Bollinger:SMA20 + 2σ // - Vegas:EMA12/144/169 三线 + 趋势判定 // - Box:Donchian 风格 20 根 lookback 箱体突破 // // 顶层 TechnicalStructure.{Support, Resistance, RangeHigh, RangeLow, LongShortLine} // 是 Intervals[primaryInterval] 的镜像(向后兼容,详见 ai/adr/0003-*.md)。 // // 数值边界:本文件内部允许 transient float64,但输出到 entity 前 // 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md // 与守卫 G12。 // // 算法参数固定为常量;不读取 config。 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" // Bollinger:周期 20、倍数 2、Squeeze 阈值按 BandwidthPct(百分比) bbPeriod = 20 bbStdMult = 2.0 bbSqueezeTight = 4.0 bbSqueezeExpanded = 12.0 // Vegas 通道 EMA 周期 vegasFast = 12 vegasMid = 144 vegasSlow = 169 // Box(Donchian-style):取最近 boxLookback 根(不含当前根)的 high/low boxLookback = 20 ) // IndicatorUsecase 实现 IndicatorComputer 接口。 // 零状态结构体——所有方法纯函数。 type IndicatorUsecase struct{} // NewIndicatorUsecase 构造器;无依赖。 func NewIndicatorUsecase() *IndicatorUsecase { return &IndicatorUsecase{} } // pivotPoint 是 pivot 检测的内部中间结构,价格已经被 parse 成 float64。 type pivotPoint struct { Price float64 Index int } // Compute 是 IndicatorComputer 的唯一对外方法。 // klinesByInterval 每个 value 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。 // primaryInterval 决定顶层 TechnicalStructure.{Support, Resistance, Range*, LongShortLine} // 镜像哪个周期;所有周期都会独立计算 IntervalTechnicals。 // longShortByInterval 同样升序;可能整体为 nil 或某个 key 缺失(如 1w,Binance 不支持)。 func (u *IndicatorUsecase) Compute( klinesByInterval map[string][]entity.Kline, primaryInterval string, longShortByInterval map[string][]entity.LongShortRatio, ) entity.TechnicalStructure { intervals := make(map[string]entity.IntervalTechnicals, len(klinesByInterval)) for iv, ks := range klinesByInterval { intervals[iv] = computeIntervalTechnicals(ks, longShortByInterval[iv]) } // 顶层镜像 primary 周期;缺 primary 时给出空骨架(向后兼容 v2.0 JSON shape)。 primary := intervals[primaryInterval] support := primary.Support if support == nil { support = []entity.TechnicalLevel{} } resistance := primary.Resistance if resistance == nil { resistance = []entity.TechnicalLevel{} } return entity.TechnicalStructure{ Support: support, Resistance: resistance, RangeHigh: primary.RangeHigh, RangeLow: primary.RangeLow, LongShortLine: primary.LongShortLine, Intervals: intervals, } } // computeIntervalTechnicals 在单周期 K 线 + 同周期 LSR 上算所有指标。 // 任一项数据不足则对应字段为 nil / 空切片。 func computeIntervalTechnicals(klines []entity.Kline, ls []entity.LongShortRatio) entity.IntervalTechnicals { closes := parseCloses(klines) highs := pivotHighs(klines, pivotLeft, pivotRight) lows := pivotLows(klines, pivotLeft, pivotRight) hi, lo := rangeHighLow(klines) return entity.IntervalTechnicals{ Bollinger: bollinger(closes), Vegas: vegas(closes), Support: clusterLevels(lows, clusterPctThreshold, sourceSupportPivot), Resistance: clusterLevels(highs, clusterPctThreshold, sourceResistancePivot), RangeHigh: hi, RangeLow: lo, LongShortLine: longShortCrossings(ls, klines, longShortCrossKeep), Box: boxBreak(klines, boxLookback), } } // parseCloses 提取 close 价位,跳过解析失败的根。 func parseCloses(klines []entity.Kline) []float64 { out := make([]float64, 0, len(klines)) for _, k := range klines { if v, ok := parsePrice(k.Close); ok { out = append(out, v) } } return out } // 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 } // sma 计算切片末尾 period 个值的简单移动均值。 // len < period 返回 ok=false。 func sma(values []float64, period int) (float64, bool) { n := len(values) if period <= 0 || n < period { return 0, false } sum := 0.0 for _, v := range values[n-period:] { sum += v } return sum / float64(period), true } // stddevPop 计算切片末尾 period 个值相对 mean 的总体标准差(除以 N)。 // 金融惯例(TradingView / TA-Lib):布林带用 population stddev,不用 sample。 func stddevPop(values []float64, period int, mean float64) (float64, bool) { n := len(values) if period <= 0 || n < period { return 0, false } sumSq := 0.0 for _, v := range values[n-period:] { d := v - mean sumSq += d * d } return math.Sqrt(sumSq / float64(period)), true } // emaLast 计算时间序列末尾的 EMA(period)。 // 初值用前 period 根的 SMA(标准做法),之后用 α=2/(period+1) 递推。 // len < period 返回 ok=false。 func emaLast(values []float64, period int) (float64, bool) { n := len(values) if period <= 0 || n < period { return 0, false } seed := 0.0 for _, v := range values[:period] { seed += v } ema := seed / float64(period) if n == period { return ema, true } alpha := 2.0 / float64(period+1) for _, v := range values[period:] { ema = alpha*v + (1-alpha)*ema } return ema, true } // bollinger 在 closes 上算 20 周期、2σ 布林带。 // 数据不足 bbPeriod 根或 mid 为 0 时返回 nil。 // Squeeze 按 BandwidthPct(百分比)分档:< 4 tight,> 12 expanded,否则 normal。 func bollinger(closes []float64) *entity.Bollinger { mid, ok := sma(closes, bbPeriod) if !ok || mid == 0 { return nil } sd, ok := stddevPop(closes, bbPeriod, mid) if !ok { return nil } upper := mid + bbStdMult*sd lower := mid - bbStdMult*sd bw := (upper - lower) / mid * 100 squeeze := "normal" switch { case bw < bbSqueezeTight: squeeze = "tight" case bw > bbSqueezeExpanded: squeeze = "expanded" } return &entity.Bollinger{ Mid: formatPrice(mid), Upper: formatPrice(upper), Lower: formatPrice(lower), BandwidthPct: formatPrice(bw), Squeeze: squeeze, } } // vegas 在 closes 上算 EMA12/144/169 + 趋势判定。 // 任一 EMA 计算失败(长度不足 169)则返回 nil。 // Trend:fast > mid > slow → bull;fast < mid < slow → bear;其余 range。 func vegas(closes []float64) *entity.Vegas { fast, ok1 := emaLast(closes, vegasFast) mid, ok2 := emaLast(closes, vegasMid) slow, ok3 := emaLast(closes, vegasSlow) if !ok1 || !ok2 || !ok3 { return nil } trend := "range" switch { case fast > mid && mid > slow: trend = "bull" case fast < mid && mid < slow: trend = "bear" } return &entity.Vegas{ EMA12: formatPrice(fast), EMA144: formatPrice(mid), EMA169: formatPrice(slow), Trend: trend, } } // boxBreak 计算 Donchian 风格的箱体突破。 // 取最近 lookback 根(不含当前根)的 High 最大值与 Low 最小值组成箱体, // 用当前根 Close 判断 status:> BoxHigh → break_up;< BoxLow → break_down;其余 inside。 // 需要 len(klines) >= lookback+1;任一价格解析失败 → nil。 func boxBreak(klines []entity.Kline, lookback int) *entity.BoxBreak { if lookback <= 0 || len(klines) < lookback+1 { return nil } n := len(klines) window := klines[n-lookback-1 : n-1] hi, lo := math.Inf(-1), math.Inf(1) for _, k := range window { h, ok1 := parsePrice(k.High) l, ok2 := parsePrice(k.Low) if !ok1 || !ok2 { return nil } if h > hi { hi = h } if l < lo { lo = l } } closeVal, ok := parsePrice(klines[n-1].Close) if !ok { return nil } status := "inside" switch { case closeVal > hi: status = "break_up" case closeVal < lo: status = "break_down" } return &entity.BoxBreak{ BoxHigh: formatPrice(hi), BoxLow: formatPrice(lo), Status: status, LookbackBars: strconv.Itoa(lookback), } }