Files
cryptoHermes/internal/usecase/indicator.go
dela 21b3078094 feat(indicator): per-interval S/R + Range + LSLine + Donchian 箱体(ADR-0003)
按 ADR-0003 把 Support / Resistance / Range / LSLine 从顶层下沉进
IntervalTechnicals,每个周期独立计算;顶层 5 字段保留为 Intervals[primary]
镜像(单一来源,不双写)以维持向后兼容的 JSON shape。

新增 BoxBreak(Donchian 风格,lookback=20 根不含当前根,K 线 < 21 → nil),
落到 IntervalTechnicals.Box。

IndicatorComputer.Compute 签名升级到 longShortByInterval map:
market_context 改为并发拉取 15m/1h/4h/1d 的全局 LSR,1w 不在 Binance LSR
支持列表 → 加 warning lsr_unsupported_interval:1w。

测试:boxBreak 6 个 case(inside/break_up/break_down/insufficient/2 个 parse
失败)+ per-interval Support/Resistance/Range 跨周期断言 + 镜像不变量 +
缺 LSR key → LongShortLine nil。indicator.go 平均覆盖 97.26%(boxBreak 100%)。
2026-05-25 10:44:33 +08:00

589 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package usecase: indicator.go 实现技术指标计算。
//
// 范围v2.x每个周期独立产出一组 IntervalTechnicals包含
// - Support / Resistancepivot 局部极值 + 0.3% 价格聚类
// - RangeHigh / RangeLow近 N 根 P95(High) / P5(Low) 线性插值
// - LongShortLine同周期 LSR 穿越 1.0 的价位中位数LSR 不可用的周期为 nil
// - BollingerSMA20 + 2σ
// - VegasEMA12/144/169 三线 + 趋势判定
// - BoxDonchian 风格 20 根 lookback 箱体突破
//
// 顶层 TechnicalStructure.{Support, Resistance, RangeHigh, RangeLow, LongShortLine}
// 是 Intervals[primaryInterval] 的镜像(向后兼容,详见 ai/adr/0003-*.md
//
// 数值边界:本文件内部允许 transient float64但输出到 entity 前
// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md
// 与守卫 G12。
//
// 算法参数固定为常量;不读取 config。
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"
// Bollinger周期 20、倍数 2、Squeeze 阈值按 BandwidthPct百分比
bbPeriod = 20
bbStdMult = 2.0
bbSqueezeTight = 4.0
bbSqueezeExpanded = 12.0
// Vegas 通道 EMA 周期
vegasFast = 12
vegasMid = 144
vegasSlow = 169
// BoxDonchian-style取最近 boxLookback 根(不含当前根)的 high/low
boxLookback = 20
)
// IndicatorUsecase 实现 IndicatorComputer 接口。
// 零状态结构体——所有方法纯函数。
type IndicatorUsecase struct{}
// NewIndicatorUsecase 构造器;无依赖。
func NewIndicatorUsecase() *IndicatorUsecase { return &IndicatorUsecase{} }
// pivotPoint 是 pivot 检测的内部中间结构,价格已经被 parse 成 float64。
type pivotPoint struct {
Price float64
Index int
}
// Compute 是 IndicatorComputer 的唯一对外方法。
// klinesByInterval 每个 value 必须按 OpenTime 升序排好KlineRepository.FindRecent 已保证)。
// primaryInterval 决定顶层 TechnicalStructure.{Support, Resistance, Range*, LongShortLine}
// 镜像哪个周期;所有周期都会独立计算 IntervalTechnicals。
// longShortByInterval 同样升序;可能整体为 nil 或某个 key 缺失(如 1wBinance 不支持)。
func (u *IndicatorUsecase) Compute(
klinesByInterval map[string][]entity.Kline,
primaryInterval string,
longShortByInterval map[string][]entity.LongShortRatio,
) entity.TechnicalStructure {
intervals := make(map[string]entity.IntervalTechnicals, len(klinesByInterval))
for iv, ks := range klinesByInterval {
intervals[iv] = computeIntervalTechnicals(ks, longShortByInterval[iv])
}
// 顶层镜像 primary 周期;缺 primary 时给出空骨架(向后兼容 v2.0 JSON shape
primary := intervals[primaryInterval]
support := primary.Support
if support == nil {
support = []entity.TechnicalLevel{}
}
resistance := primary.Resistance
if resistance == nil {
resistance = []entity.TechnicalLevel{}
}
return entity.TechnicalStructure{
Support: support,
Resistance: resistance,
RangeHigh: primary.RangeHigh,
RangeLow: primary.RangeLow,
LongShortLine: primary.LongShortLine,
Intervals: intervals,
}
}
// computeIntervalTechnicals 在单周期 K 线 + 同周期 LSR 上算所有指标。
// 任一项数据不足则对应字段为 nil / 空切片。
func computeIntervalTechnicals(klines []entity.Kline, ls []entity.LongShortRatio) entity.IntervalTechnicals {
closes := parseCloses(klines)
highs := pivotHighs(klines, pivotLeft, pivotRight)
lows := pivotLows(klines, pivotLeft, pivotRight)
hi, lo := rangeHighLow(klines)
return entity.IntervalTechnicals{
Bollinger: bollinger(closes),
Vegas: vegas(closes),
Support: clusterLevels(lows, clusterPctThreshold, sourceSupportPivot),
Resistance: clusterLevels(highs, clusterPctThreshold, sourceResistancePivot),
RangeHigh: hi,
RangeLow: lo,
LongShortLine: longShortCrossings(ls, klines, longShortCrossKeep),
Box: boxBreak(klines, boxLookback),
}
}
// parseCloses 提取 close 价位,跳过解析失败的根。
func parseCloses(klines []entity.Kline) []float64 {
out := make([]float64, 0, len(klines))
for _, k := range klines {
if v, ok := parsePrice(k.Close); ok {
out = append(out, v)
}
}
return out
}
// 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-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 为空(或所有价位都 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
}
// sma 计算切片末尾 period 个值的简单移动均值。
// len < period 返回 ok=false。
func sma(values []float64, period int) (float64, bool) {
n := len(values)
if period <= 0 || n < period {
return 0, false
}
sum := 0.0
for _, v := range values[n-period:] {
sum += v
}
return sum / float64(period), true
}
// stddevPop 计算切片末尾 period 个值相对 mean 的总体标准差(除以 N
// 金融惯例TradingView / TA-Lib布林带用 population stddev不用 sample。
func stddevPop(values []float64, period int, mean float64) (float64, bool) {
n := len(values)
if period <= 0 || n < period {
return 0, false
}
sumSq := 0.0
for _, v := range values[n-period:] {
d := v - mean
sumSq += d * d
}
return math.Sqrt(sumSq / float64(period)), true
}
// emaLast 计算时间序列末尾的 EMA(period)。
// 初值用前 period 根的 SMA标准做法之后用 α=2/(period+1) 递推。
// len < period 返回 ok=false。
func emaLast(values []float64, period int) (float64, bool) {
n := len(values)
if period <= 0 || n < period {
return 0, false
}
seed := 0.0
for _, v := range values[:period] {
seed += v
}
ema := seed / float64(period)
if n == period {
return ema, true
}
alpha := 2.0 / float64(period+1)
for _, v := range values[period:] {
ema = alpha*v + (1-alpha)*ema
}
return ema, true
}
// bollinger 在 closes 上算 20 周期、2σ 布林带。
// 数据不足 bbPeriod 根或 mid 为 0 时返回 nil。
// Squeeze 按 BandwidthPct百分比分档< 4 tight> 12 expanded否则 normal。
func bollinger(closes []float64) *entity.Bollinger {
mid, ok := sma(closes, bbPeriod)
if !ok || mid == 0 {
return nil
}
sd, ok := stddevPop(closes, bbPeriod, mid)
if !ok {
return nil
}
upper := mid + bbStdMult*sd
lower := mid - bbStdMult*sd
bw := (upper - lower) / mid * 100
squeeze := "normal"
switch {
case bw < bbSqueezeTight:
squeeze = "tight"
case bw > bbSqueezeExpanded:
squeeze = "expanded"
}
return &entity.Bollinger{
Mid: formatPrice(mid),
Upper: formatPrice(upper),
Lower: formatPrice(lower),
BandwidthPct: formatPrice(bw),
Squeeze: squeeze,
}
}
// vegas 在 closes 上算 EMA12/144/169 + 趋势判定。
// 任一 EMA 计算失败(长度不足 169则返回 nil。
// Trendfast > mid > slow → bullfast < mid < slow → bear其余 range。
func vegas(closes []float64) *entity.Vegas {
fast, ok1 := emaLast(closes, vegasFast)
mid, ok2 := emaLast(closes, vegasMid)
slow, ok3 := emaLast(closes, vegasSlow)
if !ok1 || !ok2 || !ok3 {
return nil
}
trend := "range"
switch {
case fast > mid && mid > slow:
trend = "bull"
case fast < mid && mid < slow:
trend = "bear"
}
return &entity.Vegas{
EMA12: formatPrice(fast),
EMA144: formatPrice(mid),
EMA169: formatPrice(slow),
Trend: trend,
}
}
// boxBreak 计算 Donchian 风格的箱体突破。
// 取最近 lookback 根(不含当前根)的 High 最大值与 Low 最小值组成箱体,
// 用当前根 Close 判断 status> BoxHigh → break_up< BoxLow → break_down其余 inside。
// 需要 len(klines) >= lookback+1任一价格解析失败 → nil。
func boxBreak(klines []entity.Kline, lookback int) *entity.BoxBreak {
if lookback <= 0 || len(klines) < lookback+1 {
return nil
}
n := len(klines)
window := klines[n-lookback-1 : n-1]
hi, lo := math.Inf(-1), math.Inf(1)
for _, k := range window {
h, ok1 := parsePrice(k.High)
l, ok2 := parsePrice(k.Low)
if !ok1 || !ok2 {
return nil
}
if h > hi {
hi = h
}
if l < lo {
lo = l
}
}
closeVal, ok := parsePrice(klines[n-1].Close)
if !ok {
return nil
}
status := "inside"
switch {
case closeVal > hi:
status = "break_up"
case closeVal < lo:
status = "break_down"
}
return &entity.BoxBreak{
BoxHigh: formatPrice(hi),
BoxLow: formatPrice(lo),
Status: status,
LookbackBars: strconv.Itoa(lookback),
}
}