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:
@@ -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 7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。
|
||||
// type-7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。
|
||||
// n==0 返回 0;n==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 计算 RangeHigh(P95)和 RangeLow(P5)。
|
||||
// 解析失败的价位被跳过,不污染百分位计算。
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user