Files
cryptoHermes/internal/usecase/indicator.go
dela f47a151409 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 四条)。
2026-05-24 20:31:10 +08:00

380 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package usecase: indicator.go 实现技术指标计算Milestone 6
//
// 范围v2 首版):
// - Support / Resistancepivot 局部极值 + 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-7NumPy 默认rank = (n-1)*pfloor + 小数权重插值。
// n==0 返回 0n==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 计算 RangeHighP95和 RangeLowP5
// 解析失败的价位被跳过,不污染百分位计算。
// 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
}