Files
cryptoHermes/ai/specs/coinglass.md
dela 1b61541ff5
Some checks failed
ci / build + vet + guard + race (push) Has been cancelled
feat: 接入 CoinGlass 清算热力图隔离链路
2026-05-25 16:57:22 +08:00

547 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 不合。