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:
111
ai/adr/0003-per-interval-technical-structure.md
Normal file
111
ai/adr/0003-per-interval-technical-structure.md
Normal 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 的代价更隐蔽
|
||||
|
||||
如果选 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 自验里必须全过。
|
||||
Reference in New Issue
Block a user