feat(indicator): ports + 骨架 + 完整测试 table(红灯)

为什么:先把测试与骨架同时落地,让"红灯先行"作为 commit 3 实现的
靶子。骨架函数全部返回零值,所有断言失败但无编译错误也无 panic。

变更:
- ports.go 新增 IndicatorComputer 接口;接收已 fetch 的 slice、
  避免与 MarketContextUsecase 重复 IO(G1 + G12)
- internal/usecase/indicator.go:常量、pivotPoint、9 个 helper +
  Compute 全部签名就位,实现先返零值
- internal/usecase/indicator_test.go:parsePrice/formatPrice/
  percentile/medianFloat/pivot{Highs,Lows}/clusterLevels/
  rangeHighLow/longShortCrossings/Compute 全部 table-driven
  覆盖(含 V-shape、双顶 0.18%/0.45%、三聚类、N=1/2/20 percentile、
  穿越保留 N、malformed 跳过等)
- go.mod 把 stretchr/testify v1.8.1 从 indirect 提升为 direct
This commit is contained in:
dela
2026-05-24 20:16:48 +08:00
parent c6c25d1a45
commit 6d412b2326
5 changed files with 607 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
// 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 (
"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 同样升序;可能为空。
//
// 当前实现为骨架commit 2返回零值结构让 indicator_test.go 全红。
// commit 3 填实算法。
func (u *IndicatorUsecase) Compute(
klines []entity.Kline,
longShort []entity.LongShortRatio,
) entity.TechnicalStructure {
_ = klines
_ = longShort
return entity.TechnicalStructure{
Support: []entity.TechnicalLevel{},
Resistance: []entity.TechnicalLevel{},
}
}
// parsePrice 用 strconv 把 string 价格解析成 float64。
// 失败(空 / NaN / 非数字)返回 ok=false调用者应跳过该值不 panic。
func parsePrice(s string) (float64, bool) {
_ = sort.Float64s // keep import; will be used in commit 3
_ = strconv.Itoa
return 0, false
}
// formatPrice 把 transient float64 转回 string。
// 用 'f' / prec=-1 / bitSize=64 让 FormatFloat 给出最短可往返表示。
func formatPrice(f float64) string {
return ""
}
// 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 跳过。
func pivotHighs(klines []entity.Kline, left, right int) []pivotPoint {
return nil
}
// pivotLows 对称镜像 pivotHighs按 .Low 找局部低点。
func pivotLows(klines []entity.Kline, left, right int) []pivotPoint {
return nil
}
// clusterLevels 把 pivot 点按价格相对距离聚合到同一 level。
// 算法:对 points 按价格升序排序;从最小往上扫,若当前点价格与当前 cluster
// 的均价相对距离 < pctThreshold则合并否则起新 cluster。
// 每个 cluster 输出 TechnicalLevel{Price: formatPrice(均价), Strength: strconv.Itoa(len), Source: source}。
// 输出按 Strength 降序、Price 降序(同 Strength 时高价在前)。
func clusterLevels(points []pivotPoint, pctThreshold float64, source string) []entity.TechnicalLevel {
return nil
}
// percentile 在升序排好的 sorted 上算线性插值的 p 分位p∈[0,1])。
// 经典 type 7NumPy 默认rank = (n-1)*pfloor + 小数权重插值。
// n==0 返回 0n==1 返回该唯一值。
func percentile(sortedAsc []float64, p float64) float64 {
return 0
}
// rangeHighLow 计算 RangeHighP95和 RangeLowP5
// 解析失败的价位被跳过,不污染百分位计算。
// klines 为空时返回 (nil, nil)。
func rangeHighLow(klines []entity.Kline) (high *string, low *string) {
return nil, nil
}
// longShortCrossings 在 ls 上扫描 ratio == 1.0 的穿越点。
// "穿越" 定义:相邻两条 ratio 一上一下穿过 1.0(含相等边界,但单边贴 1.0 不算)。
// 对每个穿越点,按时间戳找最近的 kline用 (open+close)/2 作为穿越价位估计。
// 保留最近 keepN 次穿越的价位,取中位数;无穿越或全部 parse 失败 → nil。
func longShortCrossings(ls []entity.LongShortRatio, klines []entity.Kline, keepN int) *string {
return nil
}
// medianFloat 计算切片中位数;空切片返回 0。会改写传入 slice内部 sort
// 调用者若不希望被改,应先 copy。
func medianFloat(xs []float64) float64 {
return 0
}