- 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 更新)
533 lines
14 KiB
Go
533 lines
14 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 的价位中位数
|
||
// - Bollinger(per interval):SMA20 + 2σ
|
||
// - Vegas(per interval):EMA12/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-7(NumPy 默认):rank = (n-1)*p,floor + 小数权重插值。
|
||
// n==0 返回 0;n==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 计算 RangeHigh(P95)和 RangeLow(P5)。
|
||
// 解析失败的价位被跳过,不污染百分位计算。
|
||
// 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。
|
||
// Trend:fast > mid > slow → bull;fast < 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,
|
||
}
|
||
}
|