# 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 自验里必须全过。