Files
cryptoHermes/internal/usecase/indicator.go
dela fa769331c2 feat(market): Phase 1 扩展 — 更多 symbol + Bollinger/Vegas + 衍生品信号
- collector.symbols 与 supportedSymbols 加 SOL/BNB/DOGE,env-default 与 README 同步
- entity 追加 TechnicalStructure.Intervals(每周期 Bollinger + Vegas,omitempty 不破坏既有字段)与 DerivativesBundle.Signal
- 新增纯解析 usecase derivatives_signal.go:fundingBias 绝对阈值、oiSignal 用 P20 baseline + 价格方向(OI up + 价格 up/down → long/short_building,funding 不参与方向)、lsrRegime 绝对阈值;signal 数据不足走 warnings 进 DataQuality
- indicator.go 加 sma/stddev/ema/bollinger/vegas + computeIntervalTechnicals;IndicatorComputer 签名收 klines map
- harness 同步:G12 扩入 derivatives_signal.go(含 Makefile G12.5/6 + ADR-0002 适用范围 + project-map / harness-health 更新)
2026-05-24 23:19:57 +08:00

533 lines
14 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 实现技术指标计算Milestone 6
//
// 范围v2 首版):
// - Support / Resistancepivot 局部极值 + 0.3% 价格聚类
// - RangeHigh / RangeLow近 N 根 P95(High) / P5(Low) 线性插值
// - LongShortLine近 N 根 LSR 穿越 1.0 的价位中位数
// - Bollingerper intervalSMA20 + 2σ
// - Vegasper intervalEMA12/144/169 三线 + 趋势判定
//
// 数值边界:本文件内部允许 transient float64但输出到 entity 前
// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md
// 与守卫 G12。
//
// 算法参数固定为常量;不读取 config——Milestone 6 不引入参数面。
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
)
// 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 指定 support/resistance/range/longShortLine 用哪个周期;
// 其它周期(包括 primary 自己)都会参与 Intervals 里的 Bollinger / Vegas。
// longShort 同样升序;可能为空。
func (u *IndicatorUsecase) Compute(
klinesByInterval map[string][]entity.Kline,
primaryInterval string,
longShort []entity.LongShortRatio,
) entity.TechnicalStructure {
primary := klinesByInterval[primaryInterval]
highs := pivotHighs(primary, pivotLeft, pivotRight)
lows := pivotLows(primary, 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(primary)
lsLine := longShortCrossings(longShort, primary, longShortCrossKeep)
intervals := make(map[string]entity.IntervalTechnicals, len(klinesByInterval))
for iv, ks := range klinesByInterval {
intervals[iv] = computeIntervalTechnicals(ks)
}
return entity.TechnicalStructure{
Support: support,
Resistance: resistance,
RangeHigh: hi,
RangeLow: lo,
LongShortLine: lsLine,
Intervals: intervals,
}
}
// computeIntervalTechnicals 在单周期 K 线上算 Bollinger 与 Vegas。
// 任一项数据不足则对应字段为 nil。
func computeIntervalTechnicals(klines []entity.Kline) entity.IntervalTechnicals {
closes := parseCloses(klines)
return entity.IntervalTechnicals{
Bollinger: bollinger(closes),
Vegas: vegas(closes),
}
}
// 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,
}
}