为什么:先把测试与骨架同时落地,让"红灯先行"作为 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
135 lines
4.8 KiB
Go
135 lines
4.8 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 (
|
||
"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 7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。
|
||
// n==0 返回 0;n==1 返回该唯一值。
|
||
func percentile(sortedAsc []float64, p float64) float64 {
|
||
return 0
|
||
}
|
||
|
||
// rangeHighLow 计算 RangeHigh(P95)和 RangeLow(P5)。
|
||
// 解析失败的价位被跳过,不污染百分位计算。
|
||
// 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
|
||
}
|