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:
dela
2026-05-24 20:31:10 +08:00
parent 6d412b2326
commit f47a151409
2 changed files with 353 additions and 31 deletions

View File

@@ -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
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 {
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。
// 算法:对 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 {
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 + 小数权重插值。
// 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 为空时返回 (nil, nil)。
// 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.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 {
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
}

View File

@@ -89,6 +89,9 @@ func TestParsePrice(t *testing.T) {
{"empty", "", 0, false},
{"non-numeric", "abc", 0, false},
{"whitespace", " 1.0 ", 0, false},
{"nan", "NaN", 0, false},
{"posinf", "Inf", 0, false},
{"neginf", "-Inf", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -128,6 +131,11 @@ func TestPercentile(t *testing.T) {
require.InDelta(t, 19.05, percentile(xs, 0.95), 1e-9)
// rank=0.95 → 1 + 0.95*(2-1) = 1.95
require.InDelta(t, 1.95, percentile(xs, 0.05), 1e-9)
// 边界 pp<=0 → 第一个元素p>=1 → 最后元素
require.Equal(t, 1.0, percentile(xs, 0.0))
require.Equal(t, 1.0, percentile(xs, -0.5))
require.Equal(t, 20.0, percentile(xs, 1.0))
require.Equal(t, 20.0, percentile(xs, 1.5))
}
// ---- medianFloat ------------------------------------------------------
@@ -410,6 +418,75 @@ func TestLongShortCrossings_KeepNLimit(t *testing.T) {
require.InDelta(t, 15.5, v, 1e-6)
}
func TestLongShortCrossings_KeepNZero(t *testing.T) {
ls := []entity.LongShortRatio{mkLSR(0, "0.9"), mkLSR(1, "1.1")}
klines := []entity.Kline{mkKline(0, "100", "100", "100", "100"), mkKline(1, "110", "110", "110", "110")}
require.Nil(t, longShortCrossings(ls, klines, 0))
require.Nil(t, longShortCrossings(ls, klines, -1))
}
func TestLongShortCrossings_MalformedRatio(t *testing.T) {
// 中间一条 LSR ratio 是 "abc",相邻两次穿越都被跳过
ls := []entity.LongShortRatio{
mkLSR(0, "0.9"),
mkLSR(1, "abc"),
mkLSR(2, "1.1"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "110", "111", "109", "110"),
mkKline(2, "120", "121", "119", "120"),
}
require.Nil(t, longShortCrossings(ls, klines, 20))
}
func TestLongShortCrossings_KlineParseFailure(t *testing.T) {
// 穿越发生,但对应 kline 的 open/close 是 malformed → 该穿越被跳过
ls := []entity.LongShortRatio{
mkLSR(0, "0.9"),
mkLSR(1, "1.1"),
}
klines := []entity.Kline{
mkKline(0, "100", "101", "99", "100"),
mkKline(1, "abc", "101", "99", "xyz"),
}
require.Nil(t, longShortCrossings(ls, klines, 20))
}
func TestLongShortCrossings_TimestampBeyondKlines(t *testing.T) {
// LSR 时间戳全部小于第一根 kline → nearestKlineIndex 返回 0
const hourMs = int64(3600 * 1000)
ls := []entity.LongShortRatio{
{Symbol: "X", Period: "1h", Timestamp: -1000, LongShortRatio: "0.9"},
{Symbol: "X", Period: "1h", Timestamp: -500, LongShortRatio: "1.1"},
}
klines := []entity.Kline{
{Symbol: "X", Interval: "1h", OpenTime: 0, CloseTime: hourMs - 1, Open: "100", High: "101", Low: "99", Close: "110", IsClosed: true},
{Symbol: "X", Interval: "1h", OpenTime: hourMs, CloseTime: 2*hourMs - 1, Open: "100", High: "101", Low: "99", Close: "110", IsClosed: true},
}
got := longShortCrossings(ls, klines, 20)
require.NotNil(t, got)
v, _ := parsePrice(*got)
require.InDelta(t, 105.0, v, 1e-6, "kline[0] (open+close)/2 = 105")
}
// nearestKlineIndex 直接覆盖空、ts 在前、ts 在后、ts 居中
func TestNearestKlineIndex(t *testing.T) {
const hourMs = int64(3600 * 1000)
require.Equal(t, -1, nearestKlineIndex(nil, 0))
klines := []entity.Kline{
{OpenTime: 0},
{OpenTime: hourMs},
{OpenTime: 2 * hourMs},
}
require.Equal(t, 0, nearestKlineIndex(klines, -1000), "before all → first")
require.Equal(t, 2, nearestKlineIndex(klines, 10*hourMs), "after all → last")
require.Equal(t, 0, nearestKlineIndex(klines, hourMs/2-1), "lo 更近")
require.Equal(t, 1, nearestKlineIndex(klines, hourMs/2+1), "hi 更近")
require.Equal(t, 0, nearestKlineIndex(klines, hourMs/2), "等距时偏向 lo<=")
require.Equal(t, 1, nearestKlineIndex(klines, hourMs), "ts 等于某点 → 命中")
}
// ---- Compute端到端编排 --------------------------------------------
func TestCompute_EmptyInputs(t *testing.T) {