- 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 四条)。
380 lines
10 KiB
Go
380 lines
10 KiB
Go
// 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
|
||
}
|