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%)。
This commit is contained in:
dela
2026-05-25 10:44:33 +08:00
parent 319e73d91f
commit 21b3078094
6 changed files with 423 additions and 39 deletions

View File

@@ -1,17 +1,21 @@
// Package usecase: indicator.go 实现技术指标计算Milestone 6
// Package usecase: indicator.go 实现技术指标计算。
//
// 范围v2 首版):
// 范围v2.x每个周期独立产出一组 IntervalTechnicals包含
// - Support / Resistancepivot 局部极值 + 0.3% 价格聚类
// - RangeHigh / RangeLow近 N 根 P95(High) / P5(Low) 线性插值
// - LongShortLine近 N 根 LSR 穿越 1.0 的价位中位数
// - Bollingerper intervalSMA20 + 2σ
// - Vegasper intervalEMA12/144/169 三线 + 趋势判定
// - 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——Milestone 6 不引入参数面
// 算法参数固定为常量;不读取 config。
package usecase
import (
@@ -51,6 +55,9 @@ const (
vegasFast = 12
vegasMid = 144
vegasSlow = 169
// BoxDonchian-style取最近 boxLookback 根(不含当前根)的 high/low
boxLookback = 20
)
// IndicatorUsecase 实现 IndicatorComputer 接口。
@@ -68,50 +75,56 @@ type pivotPoint struct {
// Compute 是 IndicatorComputer 的唯一对外方法。
// klinesByInterval 每个 value 必须按 OpenTime 升序排好KlineRepository.FindRecent 已保证)。
// primaryInterval 指定 support/resistance/range/longShortLine 用哪个周期;
// 其它周期(包括 primary 自己)都会参与 Intervals 里的 Bollinger / Vegas。
// longShort 同样升序;可能为空
// primaryInterval 决定顶层 TechnicalStructure.{Support, Resistance, Range*, LongShortLine}
// 镜像哪个周期;所有周期都会独立计算 IntervalTechnicals。
// longShortByInterval 同样升序;可能整体为 nil 或某个 key 缺失(如 1wBinance 不支持)
func (u *IndicatorUsecase) Compute(
klinesByInterval map[string][]entity.Kline,
primaryInterval string,
longShort []entity.LongShortRatio,
longShortByInterval map[string][]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)
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{}
}
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,
RangeHigh: primary.RangeHigh,
RangeLow: primary.RangeLow,
LongShortLine: primary.LongShortLine,
Intervals: intervals,
}
}
// computeIntervalTechnicals 在单周期 K 线上算 Bollinger 与 Vegas
// 任一项数据不足则对应字段为 nil。
func computeIntervalTechnicals(klines []entity.Kline) entity.IntervalTechnicals {
// 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),
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),
}
}
@@ -530,3 +543,46 @@ func vegas(closes []float64) *entity.Vegas {
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),
}
}