feat: 接入 CoinGlass 清算热力图隔离链路
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled

This commit is contained in:
dela
2026-05-25 16:57:22 +08:00
parent e5fb54cb72
commit 1b61541ff5
32 changed files with 2550 additions and 70 deletions

View File

@@ -0,0 +1,121 @@
# ADR 0004 — CoinGlass 数值边界例外扩展
**状态**Accepted
**日期**2026-05-25
**决策者**:项目维护者
**适用范围**`internal/usecase/coinglass_signal.go`(新)、`internal/usecase/coinglass_query.go`(新)、`internal/repo/webapi/coinglass/mapper.go`(新)、`internal/repo/webapi/coinglass/crypto.go`(新)、`Makefile``guard` 目标
---
## 背景
v3 phase-1 CoinGlass 接入spec`ai/specs/coinglass.md`)会在两个不同位置引入 transient `float64`
1. **`internal/usecase/coinglass_signal.go`** — 跨交易所 OI / funding / LSR 聚合做加权平均、percentile、阈值比较。和 `derivatives_signal.go` 同形态。
2. **`internal/repo/webapi/coinglass/mapper.go`** — 解密后的 plaintext JSON 通过 `json.Unmarshal` 默认进 `interface{}` 时数值就是 `float64`mapper 把这些 `float64` 映射到 entity 字段spec D6 锁定:出口 `strconv.FormatFloat(_, 'f', -1, 64)` 回 string
这两个位置和 ADR-0002 当时锁定的边界形态不完全一致:
- ADR-0002 的白名单**只**覆盖 `internal/usecase/`G12.1 grep 路径),且白名单文件名 pattern 是 `indicator|derivatives_signal`
- `coinglass_signal.go` 套用同款例外,**形态相同 → 直接扩白名单**就够。
- `repo/webapi/coinglass/mapper.go` 是**新形态**webapi 层的 transient float64。当前 G12.1 不扫 `repo/`,但下游 webapi mapper 第一次出现 `float64` 也意味着 G12 的"边界守住"语义需要明文表态。
问题:怎么把 CoinGlass 的两个 float64 位置纳入 G12而不让"白名单越扩越松"失控。
---
## 决策
**沿用 ADR-0002 的"白名单 + 出口强转 string"模式,把 G12 的语义按场景扩两块:**
### D1. usecase 层白名单扩 `coinglass`
`Makefile` `guard` 的 G12.1 第三个白名单 token
```makefile
@echo "G12.1 (usecase float)..." && ! { \
grep -rn --include="*.go" "float64\|float32" internal/usecase \
| grep -vE "indicator|derivatives_signal|coinglass" \
| grep -q . ; }
```
`coinglass` 这个 token 同时覆盖 `coinglass_signal.go``coinglass_query.go`。后者实际上**不应**用 float64query usecase 只是从 repo 拉 entity 再返回),但 token 形态保持简单——靠 code review + 同步新增的 G12.7 强约束 query 文件不引 float64 也行(见 D3 取舍)。
### D2. 新增 G12.7 / G12.8coinglass_signal 同款约束)
镜像 G12.5 / G12.6 的形态:
```makefile
@echo "G12.7 (coinglass_signal no repo)..." && ! grep -q "internal/repo" internal/usecase/coinglass_signal.go
@echo "G12.8 (coinglass_signal no DB)..." && ! grep -qE "binance|postgres|pgx" internal/usecase/coinglass_signal.go
```
`coinglass_query.go` **不**列入 no-repo 守卫——query usecase 本来就要打 repo沿用 `market_query.go` 形态)。
### D3. repo/webapi/coinglass mapper 不进 G12 扫描,但必须满足出口规则
`repo/webapi/coinglass/mapper.go``crypto.go` 内部允许 transient `float64`**不**在 G12.1 的 grep 路径里G12.1 仍只扫 `internal/usecase`)。
约束改成两层兜底:
- **静态**G12.4 不变(`internal/entity` 任何位置不得 float。CoinGlass entity 全 stringspec D6mapper 出口若漏掉 FormatFloatG12.4 会立刻 break。
- **测试**`mapper_test.go` 必须 table-driven 覆盖至少一个含小数的字段funding rate、OI 数量),断言 entity.xxx 字段是 `string` 类型且形如 `"0.0123"`——这是 mapper 行为的回归底线。
**为什么不扩 G12.1 去扫 webapi**`internal/repo/webapi/binance/` 下虽然没 float64Binance 直接返回 stringmapper 无需转换),但将来若有别的数据源走相同的"JSON number → float64 → FormatFloat 回 string"路径,每个都要单独白名单会让 G12.1 越来越糙。CoinGlass mapper 的安全网是 G12.4entity 全 string+ unit test**结构性**确保 float 不会泄漏到下游,比 grep 白名单更稳。
### D4. `coinglass_query.go` 严格禁 float64隐式
`coinglass_query.go` 从 repo 拿到的 entity 已经是 stringD3 出口规则保证usecase 内任何 float64 都属于 bug。G12.1 的 `coinglass` token 是"宽门",但 code review 阶段对 query 文件出现 float64 应该当作必改项。
---
## 理由
### 为什么不把 mapper 单独列 G12.9 grep
考虑过:
```makefile
@echo "G12.9 (coinglass mapper exit)..." && grep -qE "FormatFloat\(.*'f'" internal/repo/webapi/coinglass/mapper.go
```
否决grep 只能证明"有 FormatFloat 调用",不能证明"每个 float64 字段都过了 FormatFloat"。真正能验证后者的是 table-driven 测试断言。把"伪安全"的 grep 加进 Makefile 反而稀释 G12 的可信度——所以 D3 选择"G12.4 + 单测"双兜底,明确拒绝在 Makefile 加 mapper 专用 grep。
### 为什么不为 mapper 引入 decimal
CoinGlass JSON 数值上限远低于 ADR-0002 论证过的 1e6 USD 量级funding rate ~ ±0.01、OI 数量 ~ 1e10 BTC、清算量 ~ 1e9 USD。IEEE-754 binary64 显著位数 ~15.95 decimal digits绝对误差 << CoinGlass 自身的精度承诺(它的源数据来自交易所,再聚合一道)。引入 `shopspring/decimal` 在 mapper 这种 hot path每个 endpoint 每周期数十到数百个数值字段)反而是性能税。
### 为什么单开 ADR 而不是改 ADR-0002
ADR-0002 已 Accepted且 ADR 是不可变记录。扩白名单 = 新决策,按 he-maintainer 模式开 ADR-0004 链接回 ADR-0002 更清晰将来若再有第三个数据源走类似路径V3 phase-2 ETF Flow / CME可继续开 ADR-0005链路可追。
### 与 spec §6.3 的关系
spec `ai/specs/coinglass.md` §6.3 已经口头描述了 G12.1 扩白名单 + 新增 G12.7/G12.8。本 ADR 是这条 spec 子条款的**架构层背书**——把"为什么这样扩、不这样扩的边界在哪"固化下来,避免 PR-1 实施时 reviewer 来问"为什么 mapper 不也加 G12.9"再重新讨论。
---
## 代价
- G12 白名单从 2 个 token`indicator|derivatives_signal`)变 3 个(`+coinglass`)。每多一个 token"白名单越扩越松"的诱惑越大;本 ADR 明确:**只有 ADR 批准过的形态可以加 token**。未来增源前必须先开 ADR 再改 Makefile。
- mapper 的 float64 边界靠 entity grepG12.4+ unit test 双兜底,没有 Makefile grep 直接守。若 mapper 单测被改/删而 entity 没破理论上能漏过一段时间——缓解mapper_test.go 在 PR-1 的 test plan 里列为 must-have且未来 G12.4 一旦触发立即定位到 mapper 出口未 FormatFloat。
- 比"全栈 decimal"方案差的精度:依 IEEE-754 累积误差。CoinGlass 数据用作下游 Hermes 量化信号的"参考量级"而非"成交价 / 落库价",可接受;若某天 CoinGlass 数据要进入交易决策的资金计算路径,本 ADR 需重评(见下)。
---
## 何时重新评估
- 新增第三类 webapi mapper 走"JSON float64 → string entity"路径 → 评估是否升级到通用机制codegen mapper 或 wrapper helper而不是逐个 ADR。
- CoinGlass 数据进入资金/订单决策(目前 spec 明确不进)→ 直接复用 ADR-0002 的升级路径:换 decimal 或上 Kahan 求和。
- G12.1 白名单扩到第 5 个 token → 触发"白名单失控"复评,可能改成"显式列文件名清单"代替 grep pattern。
- `coinglass_query.go` 出现 float64PR review 抓到或 G12.1 漏过)→ 立即添加 G12.9 单独守 query 文件grep `float64` 内嵌 `coinglass_query.go` 必须空)。
---
## 与其它决策的关系
- **ADR-0001 D5全链路 string**CoinGlass entity 全 stringspec D6严格沿用无冲突。
- **ADR-0002指标数值边界**:本 ADR 是其在 v3 数据源场景的**白名单扩展 + 边界形态新增**,不修改 ADR-0002 的核心决策。
- **ADR-0003per-interval 技术结构)**无直接关系CoinGlass 数据未来若并入 `TechnicalStructure.Intervals` 是另一份 ADR 的议题。
- **守卫 G12**risk-guardrails.md:196本 ADR 落地后risk-guardrails.md 的 G12 章节需要同步:白名单文件清单加 `coinglass_signal.go`,子规则列表加 G12.7 / G12.8。该同步在 PR-1 落地 Makefile 改动时一起做。
- **`ai/specs/coinglass.md` §6.3 / §16**spec §16 已经把"暂列 ADR-0004"提前注册,本 ADR 是兑现该承诺。

View File

@@ -179,9 +179,9 @@ grep -rn --include="*.go" "websocket\|fstream.binance" .
## G11. Controller 不编排 derivatives 多源查询
**规则**`internal/controller/**` 不得直接持有或调用 `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository`。这类衍生品历史数据的"查哪些表 + 多源拼装 + 错误降级 → warnings"必须在 `internal/usecase/` 完成controller 只做参数解析、调用 usecase、返回 JSON。
**规则**`internal/controller/**` 不得直接持有或调用 `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository` / `CoinglassRepository`。这类衍生品历史数据的"查哪些表 + 多源拼装 + 错误降级 → warnings"必须在 `internal/usecase/` 完成controller 只做参数解析、调用 usecase、返回 JSON。
**为什么**:曾经 `/derivatives` handler 一次性编排了 6 处查询、4 个静默吞错的 `_`、硬编码的 limit 和响应结构——HTTP 层在替业务层做决定。要给衍生品加 taker volume、缓存、降级策略、数据质量 warnings 时,应该只动 usecase不该让 HTTP handler 跟着变厚。
**为什么**:曾经 `/derivatives` handler 一次性编排了 6 处查询、4 个静默吞错的 `_`、硬编码的 limit 和响应结构——HTTP 层在替业务层做决定。要给衍生品加 taker volume、缓存、降级策略、数据质量 warnings 时,应该只动 usecase不该让 HTTP handler 跟着变厚。CoinGlass 接入沿用同款约束v3-PR-1 起)。
**例外**`KlineRepository` 暂时允许 controller 直查,因为 `/klines` 现在就是 thin query。一旦它要加缓存 / live fallback / interval 聚合,就同样要搬进 usecase。
@@ -189,22 +189,26 @@ grep -rn --include="*.go" "websocket\|fstream.binance" .
```bash
grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
# 期望:无输出
grep -rn "CoinglassRepo\|coinglassRepo" internal/controller
# 期望无输出G11.2,由 make guard 自动跑)
```
---
## G12. 指标计算的 float64 边界
**规则**`float64``internal/usecase/` 下**只允许出现在纯解析 usecase 文件**(当前为 `indicator.go``derivatives_signal.go`)内部作为 transient 计算变量;进入 `entity` / 返回上层之前必须 `strconv.FormatFloat(_, 'f', -1, 64)` 转回 string。这些文件同时**禁止** import `internal/repo/...` 的任何子包。
**规则**`float64``internal/usecase/` 下**只允许出现在纯解析 usecase 文件**(当前为 `indicator.go``derivatives_signal.go``coinglass_signal.go`)内部作为 transient 计算变量;进入 `entity` / 返回上层之前必须 `strconv.FormatFloat(_, 'f', -1, 64)` 转回 string。这些文件同时**禁止** import `internal/repo/...` 的任何子包。
**为什么**G2 已经预留"指标计算中间过程可用 float64"的例外但没有可机械验证的边界。Milestone 6 之后会有更多指标和衍生品信号加入,必须把例外明文化、可 grep 化,避免某天 float64 悄悄出现在 entity 字段或被 repo 持久化。决策细节`ai/adr/0002-indicator-numeric-boundary.md`
`internal/repo/webapi/coinglass/mapper.go`webapi 层)允许 transient `float64`(解 JSON number 默认 float64不在 G12.1 grep 路径内;其安全边界靠 G12.4entity 全 stringCoinGlass entity 严格沿用 ADR-0001 D5+ `mapper_test.go` table-driven 双兜底。详`ai/adr/0004-coinglass-numeric-boundary-extension.md` D3
**新增同形态文件时**:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6 子规则(参考 `derivatives_signal.go` 接入的方式),并把文件名加入本规则的"当前为"括注
**为什么**G2 已经预留"指标计算中间过程可用 float64"的例外但没有可机械验证的边界。Milestone 6 之后会有更多指标和衍生品信号加入,必须把例外明文化、可 grep 化,避免某天 float64 悄悄出现在 entity 字段或被 repo 持久化。决策细节见 `ai/adr/0002-indicator-numeric-boundary.md``ai/adr/0004-coinglass-numeric-boundary-extension.md`
**新增同形态文件时**:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6/7/8 子规则(参考 `derivatives_signal.go``coinglass_signal.go` 接入的方式),并把文件名加入本规则的"当前为"括注。**只有 ADR 批准过的形态可以加 token**ADR-0004 决策)。
**怎么验证**(由 `make guard` 自动跑):
```bash
# G12.1usecase 下除白名单文件外不得出现 float64
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal"
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal|coinglass"
# 期望:无输出
# G12.2/G12.3indicator.go 不得 import repo 子包,也不得 import binance/postgres/pgx
@@ -217,6 +221,11 @@ grep -n "internal/repo" internal/usecase/derivatives_signal.go
grep -nE "binance|postgres|pgx" internal/usecase/derivatives_signal.go
# 期望:无输出
# G12.7/G12.8coinglass_signal.go 同上(文件不存在时空跑)
grep -n "internal/repo" internal/usecase/coinglass_signal.go
grep -nE "binance|postgres|pgx" internal/usecase/coinglass_signal.go
# 期望:无输出
# G12.4entity 字段任何位置不得 float
grep -rn --include="*.go" "float64\|float32" internal/entity
# 期望:无输出

546
ai/specs/coinglass.md Normal file
View File

@@ -0,0 +1,546 @@
# spec — CoinGlass V2 数据源接入v3 phase-1
**状态**AcceptedD1D7 全锁§17 用户确认 2026-05-25等 v3-PR-1 实施)
**日期**2026-05-25
**作者**cryptoHermes 维护者
**适用范围**`internal/repo/webapi/coinglass/`(新)、`internal/entity/coinglass.go`(新)、`internal/usecase/coinglass_*.go`(新)、`internal/controller/restapi/v1/coinglass_routes.go`(新)、`internal/worker/coinglass_collector.go`(新)、`config/config.go`(扩 v3 段)
参考:
- `docs/coinglass/cg.md`V2 反混淆笔记6 分支 dispatcher + 双阶段解密 + `data=` 签名)
- `docs/coinglass/coinglass_client.py`Python 参考实现2026-05 验证可跑)
- `docs/coinglass/endpoint.json`209 endpoint 存活报告154 ok
- `ai/harness-health.md` line 84新数据源 → 必填本 spec
- `README.md` 路线图 v3 段CoinGlass + ETF Flow + CME gap + … 多源聚合
---
## 1. 目标
把 CoinGlass V2 私有 API 中**跨交易所聚合**的 4 个核心信号接入 cryptoHermes作为 v3 的第一波交付。结束后下游 Hermes 量化引擎能从 `/v1/coinglass/*` 拿到 Binance 单交易所视角看不到的数据(清算热力图 + 多交易所 OI / 多空比 / funding 聚合)。
**为什么是 CoinGlass 而不是 ETF Flow / CME / 链上**
- 反混淆窗口期已开cg.md + Python ref 都已在 2026-05 验证),错过这窗口下次 CoinGlass 版本号变V3又要重新逆向。
- 4 个 endpoint 一次拿下,覆盖 README v3 段里的"多交易所聚合"子项ETF Flow / CME gap 在 CoinGlass 自己的 endpoint 里也有phase-2 直接复用本 phase 的 client 即可。
- 链上 / 宏观日历是不同性质的数据源(不需要 TLS 指纹绕过、不需要私有解密),独立铺设更经济。
---
## 2. Phase 1 endpoint 范围(**用户锁定 2026-05-25**
| Path | 用途 | 参考 params |
|---|---|---|
| `/api/index/v2/liqHeatMap` | 清算热力图(价格 × 时间 × 清算量) | `merge=true symbol=Binance_BTCUSDT interval=5 limit=288` |
| `/api/openInterest/v3/chart` | 多交易所 OI 历史 | `symbol=BTC interval=h1 timeType=1 type=0` |
| `/api/futures/longShortRate` | 跨交易所多空比 | `symbol=BTC interval=h1 timeType=1` |
| `/api/fundingRate/v2/home` | 多交易所 funding 聚合 | `(空)` |
**非目标phase 2+**ETF Flow、CVD、CME gap、宏观日历、链上稳定币流动性、其它 200 个 CoinGlass endpoint 中的 150 个。
---
## 3. 决策锁定
| ID | 决策 | 锁定时间 | 备注 |
|---|---|---|---|
| **D1** | TLS 指纹库用 **github.com/enetx/surf v1.0.144** | 2026-05-25用户后经 surf spike 修订) | `surf` Firefox profile 已真打 CoinGlass liqHeatMap能拿到 `v/user/data` 并解出 plaintextpin v1.0.144,避免 latest 抬 Go 1.25 |
| **D2** | Phase 1 = 4 个 endpoint见 §2 | 2026-05-25用户 | 全部对应 Python ref `__main__` smoke test |
| **D3** | TOTP_SECRET 走 **config 注入**env `COINGLASS_TOTP_SECRET``enabled=true` 时必填 | 2026-05-25作者 | 公开 bundle 里能抠出 → 不算账户 secret但 CoinGlass 可能旋转env 化保旋转友好Go client 不提供代码默认值 |
| **D4** | 新增 `/v1/coinglass/*` namespace**不**折进 `/v1/market/context` | 2026-05-25作者 | CoinGlass 故障模式fake-success / 密钥旋转 / TLS 失效)独立,要求物理隔离;下游 Hermes 可独立降级 |
| **D5** | 每个 endpoint 独立 PG 表 + 独立 cron 周期 | 2026-05-25作者 | 热力图频次需要更细5min、funding 聚合更粗30minschema 形状差异大 |
| **D6** | 解密管线产生的 `float64` 数值字段,在 repo 出口处全部 `strconv.FormatFloat` 转 string 进 entity | 2026-05-25作者 | 沿用 ADR-0001 D5 全链 string + ADR-0002 数值边界白名单语义。详见 §6.3 |
| **D7** | 新增独立 `CoinglassProvider` interface**不**扩 `DerivativesProvider` | 2026-05-25作者 | 失败语义不同fake-success retry返回类型不同聚合后的 list 不是单 symbol |
D1 / D2 由用户锁定D3-D7 由作者基于现有架构推断;任何一条用户不同意都可单点回旋。
---
## 4. 架构
### 4.1 分层Clean Arch 边界沿用)
```
controller/restapi/v1/coinglass_routes.go
usecase/coinglass_query.go ← 只读路径repo 拉历史
usecase/coinglass_signal.go ← 纯函数:聚合多交易所 OI/funding → 语义信号
repo/persistent/postgres/coinglass_*.go ← 5 个新表的 UpsertMany + FindRecent
repo/webapi/coinglass/ ← V2 client + 4 个 endpoint mapper
worker/coinglass_collector.go ← cron周期性 fetch → upsert
```
**G6边界守卫不变**usecase 不导入 `repo/webapi/coinglass``repo/persistent/postgres`,只对 ports。
**G12数值边界守卫扩白名单**`coinglass_signal.go` 类比 `derivatives_signal.go`,允许 `float64`;其余 usecase 文件仍禁。
### 4.2 ports
```go
// internal/usecase/ports.go 追加
type CoinglassProvider interface {
GetLiqHeatMap(ctx context.Context, symbol string, intervalMin int, limit int) (*entity.CGLiqHeatMap, error)
GetOpenInterestChart(ctx context.Context, symbol, interval string) (*entity.CGOpenInterestChart, error)
GetLongShortRate(ctx context.Context, symbol, interval string) (*entity.CGLongShortRate, error)
GetFundingRateHome(ctx context.Context) (*entity.CGFundingHome, error)
}
type CoinglassRepository interface {
UpsertLiqHeatMap(ctx context.Context, m *entity.CGLiqHeatMap) error
FindRecentLiqHeatMap(ctx context.Context, symbol string, limit int) ([]entity.CGLiqHeatMap, error)
UpsertOpenInterestChart(ctx context.Context, m *entity.CGOpenInterestChart) error
FindRecentOpenInterestChart(ctx context.Context, symbol, interval string, limit int) ([]entity.CGOpenInterestChart, error)
UpsertLongShortRate(ctx context.Context, m *entity.CGLongShortRate) error
FindRecentLongShortRate(ctx context.Context, symbol, interval string, limit int) ([]entity.CGLongShortRate, error)
UpsertFundingHome(ctx context.Context, m *entity.CGFundingHome) error
FindRecentFundingHome(ctx context.Context, limit int) ([]entity.CGFundingHome, error)
}
```
4 endpoint × (Upsert + FindRecent) = 8 repo 方法。每个独立表D5
### 4.3 V2 dispatcher 6 分支 — 必须全实现
cg.md §"Known branches (2026-05)" 写得很清楚:服务器**per-response**挑 `v`,同一 endpoint 连续两次调用 v 可能不同。客户端必须能处理所有 6 个分支,**不能 pin**。
| `v` | seed 来源 | 已确认 |
|---|---|---|
| `"0"` | `base64("coinglass"+path+"coinglass")[:16]` | V1-compat需要时再启 |
| `"1"` | `path`(待验证派生形状) | cg.md 标"shape — verify" |
| `"2"` | `response.headers.time` | V2 实战出现 |
| `"55"` | `"170b070da9654622"` | bundle 常量 |
| `"66"` | `"d6537d845a964081"` | bundle 常量 |
| `"77"` | `"863f08689c97435b"` | bundle 常量 |
实现要点(沿用 Python ref `_resolve_seed`
```go
func resolveSeed(v, path, reqCacheTsV2, respTime string) (string, error) {
switch v {
case "0": return reqCacheTsV2, nil
case "1": return path, nil
case "2": return respTime, nil
case "55": return "170b070da9654622", nil
case "66": return "d6537d845a964081", nil
case "77": return "863f08689c97435b", nil
default: return "", fmt.Errorf("unknown coinglass v=%q (bundle may have changed)", v)
}
}
```
未知 `v` → 返回错误(不重试不降级),让 collector log 一行 warn**等人工 review bundle**。这是已知"以后会出现的故障模式",预先把 hook 留好。
### 4.4 双阶段解密universal pipeline
```
Stage 1: user_key = base64(seed)[:16] 16 字节 ASCII
token = strip_quotes(gunzip(unpad(aes_ecb_decrypt(b64dec(headers["user"]), user_key))))
Stage 2: body_key = token[:16]**原 ASCII****不再 base64**
plain = strip_quotes(gunzip(unpad(aes_ecb_decrypt(b64dec(body["data"]), body_key))))
result = json.Unmarshal(plain)
```
Go 实现位置:`internal/repo/webapi/coinglass/crypto.go`,纯函数,与 fetch 解耦,方便单测喂样本。
依赖stdlib `crypto/aes` + `crypto/cipher`ECB 自己拼 block 循环stdlib 没有 ECB 模式)+ `compress/gzip` + `encoding/base64`。零第三方。
PKCS#7 unpad 自己写 8 行ECB decrypt 自己写 ~15 行(`cipher.Block.Decrypt(dst, src)` 一块一块走)。
### 4.5 请求侧 `data=` 签名TOTP
```
ts = unix_seconds_now()
totp_code = TOTP(secret=COINGLASS_TOTP_SECRET, period=30s, digits=6).At(ts)
plaintext = fmt.Sprintf("%d,%s", ts, totp_code)
cipher = AES-256-ECB-PKCS7(DATA_AES_KEY)
data_b64 = base64(cipher.Encrypt(pad(plaintext)))
url ?= ...&data=<data_b64>
```
依赖:`github.com/pquerna/otp/totp`Go TOTP 事实标准)。
`DATA_AES_KEY` = `"1f68efd73f8d4921acc0dead41dd39bc"`32 字节 ASCIIAES-256从 bundle 抠出,作为代码常量;与 TOTP_SECRET 不同 — DATA_AES_KEY 旋转概率显著低,且即便旋转也是同步换的。仍保留 env override `COINGLASS_DATA_AES_KEY` 作为 escape hatch。
### 4.6 TLS 指纹D1 = github.com/enetx/surf v1.0.144
```go
client := surf.NewClient().Builder().
Impersonate().FireFox(). // v1.0.144 Firefox/144 profile
Timeout(15 * time.Second).
Build()
```
请求头按 `coinglass_client.py` `_base_headers` 复写,但 `User-Agent` 由 surf Firefox profile 负责,避免 UA 与 TLS 指纹版本脱节。`Origin / Referer = https://www.coinglass.com``Encryption: true``Cache-Ts-V2: <ms ts>`
2026-05-25 spike 结果:`surf@v1.0.144` 在 Go 1.24.1 下真打 `/api/index/v2/liqHeatMap?merge=true&symbol=Binance_BTCUSDT&interval=5&limit=288`,返回 `status=200``v=0``user` header、非空 `data`,并可用 §4.4 双阶段解密得到 plaintext。`surf@latest v1.0.200` 也可用,但会把模块 `go` 版本抬到 1.25.0PR-1 不采用。
**Fake-success 检测**cg.md §"fake success trap"
判定(在 client 层,先于解密):
```
resp.status == 200 AND body.success == true AND body.data IS empty/null
→ fake-success → 等 2s 重试 1 次 → 仍 fake → 抛 ErrFakeSuccess
```
不区分"业务 errorsuccess=false + 明确 msg"与"fake-success",因为业务 error 调 retry 没用。两者必须明确分类。
### 4.7 配置(`config/config.go` 扩段)
```yaml
coinglass:
enabled: false # 默认关,老 v1/v2 用户零侵入
base_url: "https://capi.coinglass.com"
totp_secret: "${COINGLASS_TOTP_SECRET}" # env 注入必填enabled=true 时)
data_aes_key: "${COINGLASS_DATA_AES_KEY:-1f68efd73f8d4921acc0dead41dd39bc}"
tls_profile: "surf_firefox" # 当前实现固定 surf v1.0.144 Firefox/144 profile
request_timeout_sec: 15
fake_success_retry: 1
fake_success_retry_wait_ms: 2000
collectors:
liq_heatmap_interval_sec: 300 # 5 min
oi_chart_interval_sec: 600 # 10 min
long_short_rate_interval_sec: 600
funding_home_interval_sec: 1800 # 30 min
startup_jitter_max_sec: 30 # 启动时随机延迟 [0, max),避免整点齐打
backoff_initial_sec: 30 # 失败后退避起点
backoff_max_sec: 600 # 退避上限
single_flight: true # 任一 endpoint 不允许并发 fetch
```
**cron 行为硬约束§9 Collector 实现必须满足)**
1. **不重叠single-flight**:同一 endpoint 上一次 fetch 未结束前,下一次 tick 跳过log skip 一行),不堆积。
2. **失败退避**fetch 失败(任意错误类)后按 `backoff_initial_sec` 起步 ×2 增长到 `backoff_max_sec` 封顶;成功一次后重置回 interval。
3. **启动 jitter**:每个 collector 启动时 sleep `rand.Intn(startup_jitter_max_sec)` 秒再开始第一次 tick避免 4 endpoint 在整点齐打 CoinGlass。
PR-1 范围内:**只启 liqHeatMap 的 cron**(其它 3 个 endpoint cron 留到 v3-PR-2 接入时一起启)。但 §4.7 的 config schema 与 §9 的 backoff/jitter/single-flight 框架必须在 PR-1 落地,避免 PR-2 还要回头改 config 结构。
`enabled: false` 默认保护:未配置 TOTP_SECRET 时 cron 不启动、controller 路由 503。**G1无私钥守卫不变**TOTP_SECRET 不是账户级签名,是 CoinGlass 站点级的反爬密钥,**不签账户行为**,不需要进 G1 灰名单。但 spec 内对此事实必须记下来:
> CoinGlass TOTP_SECRET 是站点反爬签名密钥(用于 `data=` 参数),与"账户级 API 签名"性质不同,不在 G1 守卫范围内。其唯一暴露面是 CoinGlass 反爬规则,泄露不影响用户资产。`AGENTS.md §"绝不做"`不变。
---
## 5. 数据模型(`internal/entity/coinglass.go`
ADR-0001 D5所有数值字段全链 `string`。下文 *type* 都是 string 除非显式标注。
```go
package entity
// CGLiqHeatMap — /api/index/v2/liqHeatMap 响应建模。
// 注意CoinGlass 返回的 prices/volumes 是 2D 网格,要展平成行存或保 JSONB
// 这里建议保 JSONB一次拉一帧按 Symbol+Timestamp 单行)。
type CGLiqHeatMap struct {
Symbol string `json:"symbol"` // "Binance_BTCUSDT"
Interval string `json:"interval"` // "5"(分钟)
Timestamp int64 `json:"timestamp"` // 拉取时间ms
PriceMin string `json:"priceMin"`
PriceMax string `json:"priceMax"`
// RawGrid 是 CoinGlass 原 JSON 序列化字节;下游需要时再解析。
// 不在 Go entity 层重建价格×时间二维网格,避免不必要 schema 锁定。
RawGrid []byte `json:"rawGrid"`
}
// CGOpenInterestChart — 多交易所 OI 时序。一帧多交易所。
type CGOpenInterestChart struct {
Symbol string `json:"symbol"` // "BTC"
Interval string `json:"interval"` // "h1"
Timestamp int64 `json:"timestamp"`
Exchanges []CGExchangeOIPoint `json:"exchanges"`
}
type CGExchangeOIPoint struct {
Exchange string `json:"exchange"` // "Binance" / "OKX" / "Bybit" ...
OI string `json:"oi"` // USD value
}
// CGLongShortRate — 多交易所多空比。
type CGLongShortRate struct {
Symbol string `json:"symbol"`
Interval string `json:"interval"`
Timestamp int64 `json:"timestamp"`
Exchanges []CGExchangeLongShortPoint `json:"exchanges"`
}
type CGExchangeLongShortPoint struct {
Exchange string `json:"exchange"`
LongRate string `json:"longRate"` // 百分比,如 "55.32"
ShortRate string `json:"shortRate"` // 百分比
Ratio string `json:"ratio"` // long/short
}
// CGFundingHome — 多交易所 funding 聚合home page format
type CGFundingHome struct {
Timestamp int64 `json:"timestamp"`
Items []CGFundingHomeItem `json:"items"`
}
type CGFundingHomeItem struct {
Symbol string `json:"symbol"` // "BTCUSDT"
Exchange string `json:"exchange"` // "Binance"
FundingRate string `json:"fundingRate"` // e.g. "0.0001"
NextFundingTs int64 `json:"nextFundingTs"`
Interval string `json:"interval"` // "8h" / "4h"
}
```
**确认前提**:上面字段名是基于 Python ref 4 个 smoke endpoint 的"预期形状"。**真 schema 必须等 Go 端跑通 client + dump 一次解密后的 JSON 才能定**。spec 阶段先占位PR-1 spike 跑通后回填本节并把 spec 标 Accepted。
### 5.1 JSONB 字段使用纪律(强约束)
`CGLiqHeatMap.RawGrid` 是本 spec 唯一允许的 JSONB 字段。约束如下:
1. **JSONB 只承载供应商原始网格 / 元信息**CoinGlass 返回的 2D 价格×时间×清算量网格因 schema 频繁微调且行数爆炸(一帧 288 × N 价位),关系化得不偿失,故保 JSONB。
2. **usecase 层禁止直接解析 RawGrid 任意 JSON 结构**:如 `coinglass_signal.go` 需要"清算密集价位"等派生字段,必须先在 `mapper.go` 里展开成 entity 字段(如未来加 `LiqCluster []CGLiqLevel`),再由 usecase 消费。`json.Unmarshal(RawGrid, &map[string]any{})` 这种 ad-hoc 解析**禁止出现在 usecase**。
3. **JSONB → 稳定 entity 字段的演进路径**当某个派生指标稳定≥2 个 PR 反复用mapper 加一个显式字段;不要让 RawGrid 的形状沿伸到 controller / 下游 Hermes 合约。
4. **其它 3 个 entityOI / LSR / Funding保持拍平结构**,不引入新 JSONB 字段。新增 JSONB 字段需开 ADR 锁定。
---
## 6. Repo 层(`internal/repo/webapi/coinglass/`
### 6.1 目录结构
```
internal/repo/webapi/coinglass/
├── client.go # surf wrapper + data= TOTP signing + dispatch GET + retry
├── crypto.go # AES-ECB + PKCS7 + gzip零外部依赖
├── crypto_test.go # 喂 fixed ciphertext + key → 解出已知 plaintext
├── dispatcher.go # resolveSeed 6 分支
├── mapper.go # CoinGlass JSON → entityper endpoint
├── mapper_test.go # table-driven
├── liq_heatmap.go # GetLiqHeatMap
├── oi_chart.go # GetOpenInterestChart
├── long_short.go # GetLongShortRate
└── funding_home.go # GetFundingRateHome
```
### 6.2 错误分类client 层导出)
```go
var (
ErrFakeSuccess = errors.New("coinglass fake-success (TLS fingerprint suspected)")
ErrUnknownV = errors.New("coinglass unknown v branch")
ErrDecryptFail = errors.New("coinglass decrypt pipeline failed")
ErrAPIBusiness = errors.New("coinglass API business error") // success=false + msg
)
```
Collector 见到 `ErrFakeSuccess` 连发 3 次 → 全局 disable 1 小时 + log alert避免 IP 进黑名单)。
### 6.3 数值边界G12 白名单扩张)
`internal/repo/webapi/coinglass/mapper.go` 内部允许 transient `float64`(解 JSON 时 number 默认 float64但 mapper 出口(写入 entity前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 回 string。
> **G12 守卫扩白名单**Makefile `guard` 目标的 G12.1 规则增加 `coinglass` 排除:
> ```makefile
> @echo "G12.1 (usecase float)..." && ! { grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal|coinglass" | grep -q . ; }
> ```
> 同时新增 G12.7 / G12.8 覆盖:`coinglass_signal.go` 与 `derivatives_signal.go` 同款no repo / no DB import
>
> **G12.4entity 不能含 float**CoinGlass entity 全 stringG12.4 不破。
### 6.4 测试策略
| 文件 | 测试范围 | 工具 |
|---|---|---|
| `crypto_test.go` | 喂 fixed `(key, ciphertext)` → 期望 plaintextPKCS7 unpad 边界0/16 块大小) | table-driven + testify |
| `dispatcher_test.go` | 6 个 `v` 分支各一例 + unknown v → ErrUnknownV | table-driven |
| `mapper_test.go` | 喂 fixed JSON从 Python ref dump 出来)→ 期望 entity 字段NaN/Inf/缺字段处理 | table-drivenfixture 放 `testdata/` |
| `client_test.go` | 用 `httptest.NewServer` 起 fake CoinGlass验证 `data=` TOTP 签名、retry / fake-success 分类 | httptest不需要 surf 真起 Firefox 指纹,测的是上层逻辑) |
| `integration_test.go`build tag `integration_coinglass`**默认关** | 真打 CoinGlass仅在手动 + nightly 跑;不进 push CI | CGO=0 即可 |
PR-C 已经被跳,但 CoinGlass integration test 因为打外部服务,**必须**有 build tag 隔离。这是 D7 顺带的硬约束。
---
## 7. Usecase`internal/usecase/coinglass_*.go`
### 7.1 `coinglass_query.go`(薄)
只读路径:给 controller 调,从 repo 拉历史,组装 response**不做计算**。
```go
type CoinglassQueryUsecase struct {
repo CoinglassRepository
log *slog.Logger
}
func (u *CoinglassQueryUsecase) GetLiqHeatMap(ctx context.Context, symbol string, limit int) ([]entity.CGLiqHeatMap, error) { ... }
// ... 4 个 endpoint 各一个 Get
```
### 7.2 `coinglass_signal.go`(厚,纯函数)
类比 `derivatives_signal.go`:把多交易所原始数据翻译成可读语义。
```go
// CoinglassSignal 把 4 个 endpoint 的原始数据聚合成上游 Hermes 直接消费的语义字段。
type CoinglassSignal struct {
OIDominance string `json:"oiDominance"` // e.g. "binance:0.42,okx:0.28,bybit:0.18"
OIShiftBias string `json:"oiShiftBias"` // accumulating_binance / fleeing_to_okx / balanced
LSRDispersion string `json:"lsrDispersion"` // narrow / wide — 交易所间多空比方差
FundingExtreme string `json:"fundingExtreme"` // 哪个 exchange funding 是 outlier
LiqClusterPrice string `json:"liqClusterPrice"` // 清算热力图里最密集的价位(中位数)
}
type CoinglassSignalComputer interface {
Compute(
heat *entity.CGLiqHeatMap,
oi *entity.CGOpenInterestChart,
lsr *entity.CGLongShortRate,
fund *entity.CGFundingHome,
) (*CoinglassSignal, []string) // signal + warnings
}
```
**这是 phase 1 真正的"算法增量"**。前面 4 个 endpoint 是底料;这里把"7 个交易所 OI 谁在涨"翻成"oi_shift_bias=accumulating_binance"才是给 Hermes 看的成品。
### 7.3 `MarketContextUsecase` 增强(可选 phase-1.5
ADR-0003 的 mirror 思路可以借用:`/v1/market/context` 增一个 `coinglass *CGSummary` 字段可选omitempty`CoinglassSignal` 嵌入。**不在 phase-1 必做范围**phase-1 先把 `/v1/coinglass/*` 独立起来,跑稳了再考虑要不要折回 context。
---
## 8. Controller`internal/controller/restapi/v1/coinglass_routes.go`
```
GET /v1/coinglass/liq-heatmap?symbol=BTCUSDT&limit=288
GET /v1/coinglass/open-interest?symbol=BTC&interval=h1&limit=200
GET /v1/coinglass/long-short?symbol=BTC&interval=h1&limit=200
GET /v1/coinglass/funding-home
GET /v1/coinglass/signal?symbol=BTC ← 聚合 4 endpoint 的 latest调 CoinglassSignalComputer
```
`coinglass.enabled=false` 时全部返 503 `service-unavailable: coinglass disabled in config`
G11controller 不直依 derivatives repo类比controller 不直依 CoinglassRepository只调 CoinglassQueryUsecase。新增 G11.2 守卫覆盖:
```makefile
@echo "G11.2 (ctrl coinglass)..." && ! grep -rqn "CoinglassRepo\|coinglassRepo" internal/controller
```
---
## 9. Worker / Collector
`internal/worker/coinglass_collector.go`4 个独立 cron间隔见 §4.7 config。复用 `internal/worker/collector.go` 的 ticker 模式。
每个 endpoint 错误隔离 — 一个 endpoint 持续 fake-success 不影响其它 3 个的轮询。`ErrFakeSuccess` 连 3 次 → 该 endpoint disable 1h + log error。
---
## 10. 迁移与回滚
### 10.1 schema 迁移
新增 4 个表 + 0 个旧表改动:
```
migrations/00XX_create_coinglass_liq_heatmap.up.sql
migrations/00XX_create_coinglass_open_interest_chart.up.sql
migrations/00XX_create_coinglass_long_short_rate.up.sql
migrations/00XX_create_coinglass_funding_home.up.sql
```
每个表对应一个 down.sql。`migrations/` 已有 5 张 Binance 表;编号顺延。
### 10.2 回滚策略
- 配置层关:`coinglass.enabled=false` → cron 不跑、路由 503但表保留。运维零代码回滚。
- 代码层关revert PR-1 起的 4 个 commit表保留下次启用时重新 collect 填)。
---
## 11. 失败模式 & 检测
| 症状 | 含义 | 处置 |
|---|---|---|
| 持续 HTTP 200 + `success:true` + 无 data | TLS 指纹漂移 / IP 进灰名单 | 该 endpoint disable 1h + log alert运维换 IP / 升级 surf profile |
| `ErrUnknownV` | CoinGlass bundle 出了新 v 分支 | log + alert人工 re-read bundle + 加分支spec §4.3 表追加 |
| `ErrDecryptFail`gzip / unpad / utf-8 任一失败) | 密钥派生错 / 算法变 | log + alert先重试 1 次(可能是网络截断);仍失败 → human inspect dump |
| 业务 `success:false code=40001` | params 错(如缺 symbol | 不重试,直接 propagate 出错(编码 bug |
| 业务 `success:false code=40000` | 之前 fake-success 后被风控 | 同 fake-success 处置 |
| 静默 schema 变(新字段 / 字段重命名) | CoinGlass 后端微调 | mapper 容忍(按 key 选字段,不依顺序);新字段 phase-2 决定要不要接 |
监控 metric**phase-1.5 落地,不阻塞 phase-1**
- `coinglass_request_total{endpoint, outcome=ok|fake_success|business_err|unknown_v|decrypt_fail}`
- `coinglass_endpoint_last_success_age_seconds{endpoint}`
---
## 12. 守卫扩张总结
| 守卫 | 改动 |
|---|---|
| G1无账户签名 | 不变。TOTP_SECRET 不在范围(站点反爬密钥 ≠ 账户签名密钥spec §4.7 已记 |
| G6边界 | 不变。新 repo 仍只被 usecase 通过接口调 |
| G11controller 不直依 repo | 新增 G11.2 = controller 不见 `CoinglassRepo` |
| G12.1usecase float 白名单) | 扩 `coinglass``grep -vE "indicator\|derivatives_signal\|coinglass"` |
| G12.7(新) | `coinglass_signal.go` 不 import `internal/repo` |
| G12.8(新) | `coinglass_signal.go` 不 import `binance/postgres/pgx` |
| G12.9(新) | `internal/repo/webapi/coinglass/mapper.go` 出口前必须 `formatPrice`/`FormatFloat`grep 模式mapper.go 内 `float64` 必须与 `FormatFloat` 共现) — 可选phase-1 先靠 mapper_test 覆盖 |
| G13.x | 不变 |
---
## 13. PR 切分(建议,可调)
| PR | 范围 | 验收 |
|---|---|---|
| **v3-PR-1** | TLS spike + crypto.go + dispatcher.go + 1 endpointliqHeatMapvertical sliceclient / repo / usecase / controller / migration / 单 cron + §4.7 config 全骨架(含 jitter/backoff/single-flight | **硬门槛**(a) TLS fake-success 能被正确识别并抛 `ErrFakeSuccess`fake-success unit test 必过);(b) `curl /v1/coinglass/liq-heatmap?symbol=BTCUSDT` 返回非空真实响应;(c) 不触碰 `/v1/market/context` 任何字段v2 合约不动) |
| **v3-PR-2** | 剩 3 endpointOI / longShort / fundingHome+ 各自 mapper + 表 + cron | 4 个 endpoint 都能 `curl` 出数据4 个 cron 都遵守 single-flight / backoff / jitter |
| **v3-PR-3** | `CoinglassSignalComputer` 实现 + `/v1/coinglass/signal` + signal 单测;如需 RawGrid 派生指标,新增显式 entity 字段§5.1 纪律) | signal 字段语义稳,单测覆盖 ≥95%usecase 层零 RawGrid 直接解析 |
| **v3-PR-4** | 守卫扩张落 Makefile + 文档对齐AGENTS §2、harness-health 复评、README v3 ✅)+ spec 标 Accepted | `make guard` 全过README v3 段第一项 ✅ |
> **`/v1/market/context` 折回机制**phase-1 内**绝不**讨论CoinglassSignalComputer 稳定后≥2 周连续运行 + signal 字段被下游 Hermes 实际消费),再开 ADR 评估是否扩 `MarketContext` schema。PR-1/2/3 路径上任何 PR 都不能改 `/v1/market/context` 响应体。
---
## 14. 非目标(明确写下来)
- ETF Flow / CME gap / CVD / 链上 / 宏观日历 — phase 2+。
- WebSocket 推送 — 仍在 README "绝不做v1 范围外)" 范围v3 也不开。
- CoinGlass 账户级私有接口(如 portfolio— 不接。CoinGlass 没有"账户级"概念,所有数据都是聚合公开;不存在 G1 violations 风险。
- 历史回填 — Python ref 没做CoinGlass V2 也没标 history backfill endpointphase-1 起从启动那刻开始 collect。
- TLS profile 自定义(构造非常用浏览器指纹) — phase-1 沿用 `firefox_133`profile 调优留 phase-2。
---
## 15. 复评触发器
- `firefox_133` 失效CoinGlass 升级反爬)→ 评估升级 profile 或换 client。
- 4 个 endpoint 任一持续 7 天 ≥10% fake-success 率 → 评估 IP 池 / 代理策略。
- CoinGlass 出 V3 dispatcher即出现 7+ 个 `v` 分支)→ 整段 §4.3 重做。
- 下游 Hermes 要求把 CoinGlass 折回 `/v1/market/context` → phase-1.5 启动。
---
## 16. 关联 ADR / 文档
- ADR-0001架构基础D5 全链 string本 spec 严格沿用。
- ADR-0002数值边界本 spec §6.3 扩白名单到 coinglass**需要新 ADR**(暂列 ADR-0004 — 等 PR-1 落地前写定)。
- ADR-0003per-interval 技术结构无直接冲突CoinGlass 数据可未来作为新 interval/source 接入 `TechnicalStructure` 但本 phase 不做。
- `ai/risk-guardrails.md`G11/G12 扩张要同步更新v3-PR-4
- `docs/coinglass/cg.md`:本 spec 的协议事实底稿,保持单一来源;本 spec 引用而非复述。
- `docs/coinglass/coinglass_client.py`:本 spec 的算法参考实现Go 端是 1:1 移植。
---
## 17. 决策记录2026-05-25 用户确认,全部 Accepted
| # | 决策 | 内容 |
|---|---|---|
| **Q1** | namespace 独立 | 锁 `/v1/coinglass/...` 单独 namespace。`/v1/market/context` 继续 Binance-onlyPR-1/2/3 路径上**绝不**碰 v2 合约;后续融合需 CoinglassSignalComputer 稳定后通过显式版本/字段扩展开 ADR 讨论,**不在 phase-1 范围**。 |
| **Q2** | cron cadence 接受 | 5/10/10/30 minliqHeatMap / OI / LSR / Funding采用。强制三条实现约束**single-flight 不重叠 + 失败指数退避 + 启动 jitter**。详见 §4.7 / §9。PR-1 范围内仅启 liqHeatMap 的 cron。 |
| **Q3** | entity 形状方向 OK | RawGrid JSONB 适用于 liqHeatMap避免行爆炸其它 3 个 entity 拍平。string-numeric 全链路保持,零 float64 出 mapper 边界。新增强约束:**JSONB 仅供应商原始网格 / 元信息usecase 不允许直接解析任意 JSON 结构,需要稳定字段时由 mapper 展开成 entity 字段**§5.1)。 |
| **Q4** | 保持 4 PR | 不合并 PR-2 进 PR-1。PR-1 已含 TLS spike + 解密 + 1 endpoint + repo/usecase/controller/cron风险足够大额外 3 endpoint 塞入会模糊失败归因。按 §13 4 PR 切分推进。 |
**v3-PR-1 硬门槛**(再强调,对应 §13 PR-1 验收):
1. TLS fake-success 识别通过(`ErrFakeSuccess` 单测覆盖 + 真打 CoinGlass 时能正确分类)。
2. liqHeatMap vertical slice 打通client → repo → usecase → controller → cron 全链,`curl /v1/coinglass/liq-heatmap` 出真实非空数据)。
3. v2 合约零变更(`/v1/market/context` 响应体 diff = 0
任一未达标PR-1 不合。