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

@@ -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 的代价更隐蔽
如果选 CREADME 路线图要先改(把"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 D2Clean Arch 边界)**entity 字段扩展不影响层间依赖方向。
- **ADR-0002指标数值边界**:本 ADR 不引入新 float 出口,所有 per-interval 字段沿用既有 string 表示与 G12 白名单G12 无需修改。
- **守卫 G6 / G11 / G12**:本次变更不应触发任何已有守卫违规;`make guard` 在 PR-A 自验里必须全过。