diff --git a/ai/adr/0003-per-interval-technical-structure.md b/ai/adr/0003-per-interval-technical-structure.md new file mode 100644 index 0000000..9e23c7e --- /dev/null +++ b/ai/adr/0003-per-interval-technical-structure.md @@ -0,0 +1,111 @@ +# ADR 0003 — Per-interval 技术结构升级 + +**状态**:Accepted +**日期**:2026-05-25 +**决策者**:项目维护者 +**适用范围**:`internal/entity/market_context.go`、`internal/usecase/indicator.go`、`internal/usecase/ports.go`、`internal/usecase/market_context.go` + +--- + +## 背景 + +v2.0 把技术结构第一波(pivot S/R + P95/P5 区间 + LSR 穿越价位中位数)做了出来,但只算 primary `1h`:`TechnicalStructure.Support / Resistance / RangeHigh / RangeLow / LongShortLine` 全部在顶层、只有一份。 + +v2.x 路线图(README 末尾)要把 MA / Bollinger / Vegas / 箱体做成 **per-interval** 指标。第一批 Bollinger / Vegas 已经落到 `IntervalTechnicals{Bollinger, Vegas}` 里,跑通了 `Intervals map[string]IntervalTechnicals` 的承载层(ADR-0002 顺带确立)。 + +剩下的问题:Support / Resistance / Range / LSLine 与新加的 Box 是否也应该 per-interval?如果要,顶层那 5 个字段怎么处理?这是 schema 决策,影响下游 Hermes 的读路径。 + +--- + +## 候选方案 + +1. **A:扩展 + 顶层镜像** + - `IntervalTechnicals` 加 6 字段(Support / Resistance / RangeHigh / RangeLow / LongShortLine / Box)。 + - `TechnicalStructure` 顶层那 5 个原有字段**保留**,作为 `Intervals[primary]` 的镜像。 + - 镜像由 indicator 出口处单点赋值(取 `Intervals[primaryInterval]`,避免双写漂移)。 + +2. **B:顶层只剩 Primary 字段 + 全下沉** + - `TechnicalStructure` 顶层只留 `Primary string` 指针 + `Intervals`,原 5 字段全部下沉。 + - 结构最干净,无冗余。 + +3. **C:暂时不升级,只新增 Box** + - 保留 IntervalTechnicals 当前的 BB + Vegas,仅在 `Intervals` 加 `Box`;Support / Resistance / Range / LSLine 继续仅在 primary。 + - 承认 README "per-interval 化" 范围过宽,缩减 v2.x 范围。 + +--- + +## 决策 + +选 **方案 A:扩展 + 顶层镜像**。 + +具体 entity 形态: + +```go +type TechnicalStructure struct { + // primary interval 镜像,向后兼容 + Support []TechnicalLevel `json:"support"` + Resistance []TechnicalLevel `json:"resistance"` + RangeHigh *string `json:"rangeHigh"` + RangeLow *string `json:"rangeLow"` + LongShortLine *string `json:"longShortLine"` + // per-interval 真正承载层 + Intervals map[string]IntervalTechnicals `json:"intervals"` +} + +type IntervalTechnicals struct { + Bollinger *Bollinger `json:"bollinger,omitempty"` + Vegas *Vegas `json:"vegas,omitempty"` + Support []TechnicalLevel `json:"support,omitempty"` + Resistance []TechnicalLevel `json:"resistance,omitempty"` + RangeHigh *string `json:"rangeHigh,omitempty"` + RangeLow *string `json:"rangeLow,omitempty"` + LongShortLine *string `json:"longShortLine,omitempty"` + Box *BoxBreak `json:"box,omitempty"` +} +``` + +镜像规则:`indicator.Compute` 计算完所有周期后,把 `Intervals[primaryInterval]` 的 5 个对应字段拷一份到顶层。这是**唯一写顶层**的位置,避免双写。 + +--- + +## 理由 + +### 向后兼容是当前最贵的属性 + +下游 Hermes 已经在消费 `/v1/market/context` 的 `technical.support / resistance / rangeHigh / ...`。v2.x 是"指标第二波扩充",不是"breaking 改版"。方案 B 的清爽换不来等额收益——下游必须改一次读路径,而我们目前没有 v1→v2 schema 迁移的成本预算。 + +### ADR-0002 已经预设了承载层 + +ADR-0002 引入 `IntervalTechnicals` 时,把它定义成"per-interval 指标的新家"。当时只填了 Bollinger / Vegas,但留出的空位本来就是给后续 per-interval 算法的。方案 A 沿着这条已建好的轨道走,方案 B 会推翻刚立的承载层语义。 + +### 单一来源的镜像 vs 真正的冗余 + +担心"顶层和 Intervals[primary] 不一致"是合理的——这正是为什么镜像必须由 indicator 出口处**单点赋值**,而不是两条路径分别计算。只要这条规则进 `indicator.go` 内部约束(不暴露 SetTopLevel API),漂移风险可控。代码审查可以加一条简单 grep:除了 `Compute` 函数返回前那一段,`internal/usecase/` 不许再写 `TechnicalStructure.{Support,Resistance,RangeHigh,RangeLow,LongShortLine} =`。 + +### 方案 C 的代价更隐蔽 + +如果选 C,README 路线图要先改(把"per-interval 化"从 v2.x 范围里删掉)。改文档比改代码便宜,但 ai/harness-health 的复评触发器、未来 v3 接 CoinGlass per-symbol 数据时的扩展点都会被这次妥协绊住。不如一次做对。 + +--- + +## 代价 + +- `IntervalTechnicals` 字段从 2 个涨到 8 个,类型本身变重。omitempty 让缺数据周期(如 1w 缺 LSLine)不出现在 JSON 里。 +- `IndicatorComputer.Compute` 签名要从 `longShort []LongShortRatio` 改成 `longShortByInterval map[string][]LongShortRatio`——breaking 的内部 API 变化,调用方只有 `market_context.go` 一处,但要同步改 market_context 内的 LSR 多周期拉取(详见 PR-A 实施细节)。 +- 镜像规则需要单测保护:必须有用例断言"顶层字段 == Intervals[primary] 对应字段"。 + +--- + +## 何时重新评估 + +- 下游消费方明确说"不再读顶层字段,只走 Intervals" → 把顶层 5 字段标 deprecated,下个大版本删除(升级到方案 B)。 +- `IntervalTechnicals` 字段涨到 15+ 且出现明显的"高频读子集 vs 全量读子集"分化 → 考虑拆 `IntervalTechnicalsCore` + `IntervalTechnicalsExtended`。 +- 出现"顶层与 Intervals[primary] 不一致"的 bug → 把镜像断言移入 G14 守卫,加进 `make guard`。 + +--- + +## 与其它决策的关系 + +- **ADR-0001 D2(Clean Arch 边界)**:entity 字段扩展不影响层间依赖方向。 +- **ADR-0002(指标数值边界)**:本 ADR 不引入新 float 出口,所有 per-interval 字段沿用既有 string 表示与 G12 白名单,G12 无需修改。 +- **守卫 G6 / G11 / G12**:本次变更不应触发任何已有守卫违规;`make guard` 在 PR-A 自验里必须全过。 diff --git a/internal/entity/market_context.go b/internal/entity/market_context.go index 7ae01e1..8eb8e81 100644 --- a/internal/entity/market_context.go +++ b/internal/entity/market_context.go @@ -51,8 +51,14 @@ type TechnicalStructure struct { } type IntervalTechnicals struct { - Bollinger *Bollinger `json:"bollinger,omitempty"` - Vegas *Vegas `json:"vegas,omitempty"` + Bollinger *Bollinger `json:"bollinger,omitempty"` + Vegas *Vegas `json:"vegas,omitempty"` + Support []TechnicalLevel `json:"support,omitempty"` + Resistance []TechnicalLevel `json:"resistance,omitempty"` + RangeHigh *string `json:"rangeHigh,omitempty"` + RangeLow *string `json:"rangeLow,omitempty"` + LongShortLine *string `json:"longShortLine,omitempty"` + Box *BoxBreak `json:"box,omitempty"` } // Bollinger 是 20 周期、2 倍标准差的布林带。 @@ -74,6 +80,16 @@ type Vegas struct { Trend string `json:"trend"` } +// BoxBreak 是 Donchian 风格的 N 根 lookback 箱体突破信号。 +// BoxHigh / BoxLow 取最近 LookbackBars 根(不含当前根)的最高高 / 最低低; +// Status: inside / break_up / break_down,由最后一根 K 线收盘相对箱体边判定。 +type BoxBreak struct { + BoxHigh string `json:"boxHigh"` + BoxLow string `json:"boxLow"` + Status string `json:"status"` + LookbackBars string `json:"lookbackBars"` +} + type TechnicalLevel struct { Price string `json:"price"` Strength string `json:"strength,omitempty"` diff --git a/internal/usecase/indicator.go b/internal/usecase/indicator.go index d1b2571..98ffed7 100644 --- a/internal/usecase/indicator.go +++ b/internal/usecase/indicator.go @@ -1,17 +1,21 @@ -// Package usecase: indicator.go 实现技术指标计算(Milestone 6)。 +// Package usecase: indicator.go 实现技术指标计算。 // -// 范围(v2 首版): +// 范围(v2.x):每个周期独立产出一组 IntervalTechnicals,包含 // - 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 三线 + 趋势判定 +// - LongShortLine:同周期 LSR 穿越 1.0 的价位中位数(LSR 不可用的周期为 nil) +// - Bollinger:SMA20 + 2σ +// - Vegas:EMA12/144/169 三线 + 趋势判定 +// - Box:Donchian 风格 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 + + // Box(Donchian-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 缺失(如 1w,Binance 不支持)。 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), + } +} diff --git a/internal/usecase/indicator_test.go b/internal/usecase/indicator_test.go index 0801a0a..41fd3ae 100644 --- a/internal/usecase/indicator_test.go +++ b/internal/usecase/indicator_test.go @@ -631,3 +631,169 @@ func TestCompute_IntervalsPopulated(t *testing.T) { require.NotNil(t, out.Intervals["4h"].Bollinger) require.NotNil(t, out.Intervals["4h"].Vegas) } + +// ---- boxBreak --------------------------------------------------------- + +// mkFlatKline 生成单根 K 线,all 价位相同(用于构造箱体内 baseline)。 +func mkFlatKline(idx int64, price float64) entity.Kline { + s := strconv.FormatFloat(price, 'f', -1, 64) + return mkKline(idx, s, s, s, s) +} + +func TestBoxBreak_Inside(t *testing.T) { + // 21 根:前 20 根 high=110、low=90 → box [90,110];最后一根 close=100 → inside + klines := make([]entity.Kline, 21) + for i := 0; i < 20; i++ { + klines[i] = mkKline(int64(i), "100", "110", "90", "100") + } + klines[20] = mkKline(20, "100", "101", "99", "100") + got := boxBreak(klines, boxLookback) + require.NotNil(t, got) + require.Equal(t, "110", got.BoxHigh) + require.Equal(t, "90", got.BoxLow) + require.Equal(t, "inside", got.Status) + require.Equal(t, "20", got.LookbackBars) +} + +func TestBoxBreak_BreakUp(t *testing.T) { + // 前 20 根 [90,110];最后一根 close=120 > 110 → break_up + klines := make([]entity.Kline, 21) + for i := 0; i < 20; i++ { + klines[i] = mkKline(int64(i), "100", "110", "90", "100") + } + klines[20] = mkKline(20, "115", "121", "114", "120") + got := boxBreak(klines, boxLookback) + require.NotNil(t, got) + require.Equal(t, "break_up", got.Status) + // 当前根不参与箱体计算:边界仍是 110/90 + require.Equal(t, "110", got.BoxHigh) + require.Equal(t, "90", got.BoxLow) +} + +func TestBoxBreak_BreakDown(t *testing.T) { + klines := make([]entity.Kline, 21) + for i := 0; i < 20; i++ { + klines[i] = mkKline(int64(i), "100", "110", "90", "100") + } + klines[20] = mkKline(20, "92", "93", "85", "85") + got := boxBreak(klines, boxLookback) + require.NotNil(t, got) + require.Equal(t, "break_down", got.Status) + require.Equal(t, "90", got.BoxLow) +} + +func TestBoxBreak_InsufficientData(t *testing.T) { + // 仅 20 根(< lookback+1)→ nil + klines := make([]entity.Kline, 20) + for i := 0; i < 20; i++ { + klines[i] = mkFlatKline(int64(i), 100) + } + require.Nil(t, boxBreak(klines, boxLookback)) + require.Nil(t, boxBreak(nil, boxLookback), "nil 输入") + require.Nil(t, boxBreak(klines, 0), "lookback=0") + require.Nil(t, boxBreak(klines, -1), "negative lookback") +} + +func TestBoxBreak_MalformedPrice(t *testing.T) { + // 窗口内任意根价格 parse 失败 → 整体返回 nil + klines := make([]entity.Kline, 21) + for i := 0; i < 20; i++ { + klines[i] = mkKline(int64(i), "100", "110", "90", "100") + } + klines[5] = mkKline(5, "100", "abc", "90", "100") // bad high + klines[20] = mkKline(20, "100", "101", "99", "100") + require.Nil(t, boxBreak(klines, boxLookback)) +} + +func TestBoxBreak_MalformedClose(t *testing.T) { + // 当前根 close parse 失败 → nil + klines := make([]entity.Kline, 21) + for i := 0; i < 20; i++ { + klines[i] = mkKline(int64(i), "100", "110", "90", "100") + } + klines[20] = mkKline(20, "100", "101", "99", "xyz") + require.Nil(t, boxBreak(klines, boxLookback)) +} + +// ---- Compute per-interval(ADR-0003 镜像不变量)------------------------- + +// TestCompute_PerIntervalSRAndMirror 是 ADR-0003 的核心守卫: +// (a) 每个有足够 kline 的周期都独立产出 Support/Resistance/Range; +// (b) 顶层 5 字段必须 == Intervals[primaryInterval] 的对应字段(单一来源镜像)。 +// 同时覆盖:缺 LSR key 的周期 LongShortLine 为 nil(对应 1w warning 的算法侧契约)。 +func TestCompute_PerIntervalSRAndMirror(t *testing.T) { + // V-shape 21 根:1 个 pivotLow、0 个 pivotHigh + vshape := func() []entity.Kline { + out := make([]entity.Kline, 21) + for i := 0; i < 21; i++ { + var p float64 + if i <= 10 { + p = 100 - float64(i)*1.0 + } else { + p = 90 + float64(i-10)*1.0 + } + out[i] = mkKline(int64(i), + strconv.FormatFloat(p, 'f', -1, 64), + strconv.FormatFloat(p+0.5, 'f', -1, 64), + strconv.FormatFloat(p, 'f', -1, 64), + strconv.FormatFloat(p+0.25, 'f', -1, 64), + ) + } + return out + } + // 4h 周期带 LSR 穿越(0.9 → 1.1) + lsr4h := []entity.LongShortRatio{mkLSR(0, "0.9"), mkLSR(1, "1.1")} + + u := NewIndicatorUsecase() + out := u.Compute( + map[string][]entity.Kline{ + "1h": vshape(), + "4h": vshape(), + "1w": vshape(), // 1w 无 LSR(模拟 Binance 不支持) + }, + "4h", + map[string][]entity.LongShortRatio{ + "1h": {mkLSR(0, "0.5"), mkLSR(1, "0.7")}, // 全在 1 下方 → 无穿越 + "4h": lsr4h, + // 故意不放 "1w" + }, + ) + + // (a) per-interval Support 非空 + require.NotEmpty(t, out.Intervals["1h"].Support, "1h V-shape 必有 pivot low") + require.NotEmpty(t, out.Intervals["4h"].Support) + require.NotEmpty(t, out.Intervals["1w"].Support) + require.NotNil(t, out.Intervals["4h"].RangeHigh) + require.NotNil(t, out.Intervals["4h"].RangeLow) + + // LongShortLine:4h 有穿越 → 非 nil;1h 无穿越 → nil;1w 缺 key → nil + require.NotNil(t, out.Intervals["4h"].LongShortLine, "4h 有穿越") + require.Nil(t, out.Intervals["1h"].LongShortLine, "1h LSR 不穿越 1.0") + require.Nil(t, out.Intervals["1w"].LongShortLine, "1w 缺 LSR key → nil") + + // (b) 顶层镜像 primary=4h(ADR-0003 单一来源不变量) + // 注意:顶层把 nil 归一为空切片(v2.0 JSON shape),所以用 ElementsMatch + // 而不是严格 Equal 来表达"语义同源"。RangeHigh/Low/LongShortLine 是 *string + // 共享同一指针,直接 Equal 即可。 + require.ElementsMatch(t, out.Intervals["4h"].Support, out.Support, "顶层 Support 与 Intervals[primary] 同源") + require.ElementsMatch(t, out.Intervals["4h"].Resistance, out.Resistance) + require.Equal(t, out.Intervals["4h"].RangeHigh, out.RangeHigh) + require.Equal(t, out.Intervals["4h"].RangeLow, out.RangeLow) + require.Equal(t, out.Intervals["4h"].LongShortLine, out.LongShortLine) +} + +// TestCompute_MissingPrimaryGivesEmptySkeleton:primary 不在 Intervals 时, +// 顶层 Support/Resistance 必须是空切片(非 nil,向后兼容 v2.0 JSON shape)。 +func TestCompute_MissingPrimaryGivesEmptySkeleton(t *testing.T) { + u := NewIndicatorUsecase() + out := u.Compute( + map[string][]entity.Kline{"1h": ascending(5, 100, 1)}, + "4h", // primary 不存在 + nil, + ) + require.NotNil(t, out.Support) + require.NotNil(t, out.Resistance) + require.Empty(t, out.Support) + require.Empty(t, out.Resistance) + require.Nil(t, out.RangeHigh, "primary 缺席 → 顶层 RangeHigh 也为 nil") +} diff --git a/internal/usecase/market_context.go b/internal/usecase/market_context.go index 8cf230d..64a98a1 100644 --- a/internal/usecase/market_context.go +++ b/internal/usecase/market_context.go @@ -23,6 +23,10 @@ var supportedSymbols = map[string]bool{ var supportedIntervals = []string{"15m", "1h", "4h", "1d", "1w"} +// lsrSupportedIntervals 是 Binance Futures Global LSR 接口支持的 period 集合。 +// 1w 不在列表内 → 该周期 IntervalTechnicals.LongShortLine 为 nil,外加 warning。 +var lsrSupportedIntervals = []string{"15m", "1h", "4h", "1d"} + const ( klineWindowSize = 300 klineMinForOK = 200 @@ -164,10 +168,31 @@ func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entit addWarn("OI history query failed: " + err.Error()) } - globalLS, err := u.lsRepo.FindRecent(ctx, symbol, derivativePeriod, entity.RatioTypeGlobalAccount, longShortLen) - if err != nil { - addWarn("global long/short query failed: " + err.Error()) + globalLSByInterval := make(map[string][]entity.LongShortRatio, len(lsrSupportedIntervals)) + var lsMu sync.Mutex + lsG, lsCtx := errgroup.WithContext(ctx) + for _, iv := range lsrSupportedIntervals { + iv := iv + lsG.Go(func() error { + rows, err := u.lsRepo.FindRecent(lsCtx, symbol, iv, entity.RatioTypeGlobalAccount, longShortLen) + if err != nil { + addWarn(fmt.Sprintf("global long/short query failed for %s: %v", iv, err)) + return nil + } + lsMu.Lock() + globalLSByInterval[iv] = rows + lsMu.Unlock() + return nil + }) } + _ = lsG.Wait() + for _, iv := range supportedIntervals { + if !lsrSupports(iv) { + addWarn("lsr_unsupported_interval:" + iv) + } + } + globalLS := globalLSByInterval[derivativePeriod] + topLS, err := u.lsRepo.FindRecent(ctx, symbol, derivativePeriod, entity.RatioTypeTopTraderPosition, longShortLen) if err != nil { addWarn("top trader position long/short query failed: " + err.Error()) @@ -202,7 +227,7 @@ func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entit Snapshot: snapshot, Klines: klines, Derivatives: derivBundle, - Technical: u.indicator.Compute(klines, derivativePeriod, globalLS), + Technical: u.indicator.Compute(klines, derivativePeriod, globalLSByInterval), DataQuality: entity.DataQuality{ Source: "binance", Warnings: warnings, @@ -218,3 +243,12 @@ func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entit } return out, nil } + +func lsrSupports(interval string) bool { + for _, iv := range lsrSupportedIntervals { + if iv == interval { + return true + } + } + return false +} diff --git a/internal/usecase/ports.go b/internal/usecase/ports.go index 2425631..511767d 100644 --- a/internal/usecase/ports.go +++ b/internal/usecase/ports.go @@ -59,14 +59,15 @@ type TakerVolumeRepository interface { // 重复 IO。 // // klinesByInterval 是按周期分组的 K 线 map(每周期一份升序切片); -// primaryInterval 指定哪个周期参与 support/resistance/range/longShortLine -// 的计算(其它周期只参与 Intervals 多周期指标如 Bollinger / Vegas)。 +// primaryInterval 决定顶层 TechnicalStructure 镜像哪个周期;所有周期 +// 都会独立填充进 Intervals。longShortByInterval 按周期分组的 LSR map +// (某周期缺失时,该周期 IntervalTechnicals.LongShortLine 为 nil)。 // 实现可见 internal/usecase/indicator.go。 type IndicatorComputer interface { Compute( klinesByInterval map[string][]entity.Kline, primaryInterval string, - longShort []entity.LongShortRatio, + longShortByInterval map[string][]entity.LongShortRatio, ) entity.TechnicalStructure }