257 lines
12 KiB
Markdown
257 lines
12 KiB
Markdown
# Risk Guardrails — cryptoHermes
|
||
|
||
可机械验证 / 不可逾越的规则。每条规则给出**为什么**、**怎么验证**。
|
||
|
||
任何 PR 都应该满足这里全部规则。
|
||
|
||
---
|
||
|
||
## G1. 绝不接入交易能力(Hard Stop)
|
||
|
||
**规则**:本服务只读公共行情。**禁止**任何下单、撤单、查询账户/持仓/资金、签名请求、托管、转账。
|
||
|
||
**为什么**:项目定位是"上游 Hermes 量化分析引擎的行情网关"。一旦引入交易能力,安全边界、合规风险、密钥管理责任全部上升一个数量级。`docs/dev.md` 第 1 章原文:"本服务只提供市场数据与分析上下文,不做交易、不下单、不托管资金"。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
# Go 代码不可出现私钥/签名相关字段
|
||
grep -ri --include="*.go" "apikey\|x-mbx-apikey\|hmac\|secret_key\|api_secret" .
|
||
# 期望:无输出(注释/字符串/文档中说明"不接入"不算)
|
||
|
||
# Binance 私有接口前缀不可出现
|
||
grep -r --include="*.go" "fapi/v1/order\|fapi/v1/account\|fapi/v2/account\|fapi/v2/positionRisk" .
|
||
# 期望:无输出
|
||
```
|
||
|
||
**例外**:无。如果上游需求变化要做交易,**必须先开新仓库**或新模块(独立鉴权 / 独立部署),不能在本仓库内直接放开。
|
||
|
||
---
|
||
|
||
## G2. 价格 / 成交量绝不用 float64(Hard Stop)
|
||
|
||
**规则**:所有金额、价格、成交量字段**全链路用 `string`**。DTO、entity、JSON 响应都是 `string`。落库时由 PG 转 `NUMERIC(36,18)`。
|
||
|
||
**为什么**:`108000.12` 这种价格 float64 表示就会丢精度。Binance 原样返回字符串,本服务保留字符串到 PG。这一点在所有 5 个 entity 文件里都已经做到了,新代码必须遵守。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
# 任何新增 entity 字段如果叫 *Price/*Volume/*Quantity/*Amount,类型必须是 string
|
||
grep -rn --include="*.go" "Price\s*float\|Volume\s*float\|Quantity\s*float\|Amount\s*float" internal/entity
|
||
# 期望:无输出
|
||
```
|
||
|
||
**例外**:纯统计/分析(如指标计算的中间过程)可以用 float64,但**结果落库或返回 API 时必须转回 string**。
|
||
|
||
---
|
||
|
||
## G3. 未收线 K 线(IsClosed=false)不入库
|
||
|
||
**规则**:从 Binance 拉到的最新一根 K 线如果 `close_time > now()`,**不能写入 `market_klines`**。
|
||
|
||
**为什么**:未收线 K 线的 close/high/low/volume 还会变。如果入库,后续不更新就是脏数据,更新又会触发 upsert 风暴。`internal/repo/persistent/postgres/kline_repo.go` 的 `UpsertMany` 已经过滤 `IsClosed=false`,`internal/repo/webapi/binance/mapper.go` 已经自动设置 `IsClosed = closeTime < now()`。新代码(含 backfill)必须遵守。
|
||
|
||
**怎么验证**:手动跑一次 collector,立刻 `SELECT count(*) FROM market_klines WHERE close_time > extract(epoch from now()) * 1000` 应为 0。
|
||
|
||
---
|
||
|
||
## G4. UpsertMany 必须幂等
|
||
|
||
**规则**:所有 repo 的 `UpsertMany` 用 `INSERT ... ON CONFLICT (<PK>) DO UPDATE SET ... updated_at = now()`。**禁止** `INSERT` 不带 ON CONFLICT 或用 `INSERT ... ON CONFLICT DO NOTHING`(后者错过了更新场景)。
|
||
|
||
**为什么**:cron 会重复跑、backfill 会和 cron 重叠、重启会重拉。重复执行必须不能产生重复行也不能丢更新(例如 OI 后修正、close 值微调)。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
make backfill ARGS="--symbol BTCUSDT --interval 1h --limit 100"
|
||
psql $DATABASE_URL -c "SELECT count(*) FROM market_klines WHERE symbol='BTCUSDT' AND interval='1h'"
|
||
make backfill ARGS="--symbol BTCUSDT --interval 1h --limit 100"
|
||
psql $DATABASE_URL -c "SELECT count(*) FROM market_klines WHERE symbol='BTCUSDT' AND interval='1h'"
|
||
# 期望:两次 count 一致
|
||
```
|
||
|
||
---
|
||
|
||
## G5. Rate Limit 不可绕开
|
||
|
||
**规则**:所有外部 HTTP 调用走 `pkg/httpclient`,该 client 已挂 `golang.org/x/time/rate`(默认 20 req/s,burst 40)。**禁止** 在业务代码里直接用 `net/http` 调 Binance。
|
||
|
||
**为什么**:Binance USDⓈ-M Futures IP 级限额 2400 weight/min。一旦超限会被封 IP 几分钟到几小时。本地代理在国内是稀缺资源,封了影响整个开发环境。
|
||
|
||
**预算**:新增 collector 任务前估算 weight:
|
||
- `GET /fapi/v1/klines`:1(limit ≤ 100)→ 10(limit ≤ 1000)
|
||
- `GET /fapi/v1/ticker/24hr`:1(单 symbol)/ 40(全量)
|
||
- `GET /fapi/v1/openInterest`:1
|
||
- `GET /futures/data/*`:1
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
grep -rn --include="*.go" "net/http\"" internal/repo/webapi
|
||
# 期望:只在 pkg/httpclient 里,repo/webapi 不应直接 import net/http
|
||
```
|
||
|
||
---
|
||
|
||
## G6. Clean Architecture 边界
|
||
|
||
**规则**:见 `ai/project-map.md` "不可逾越的边界"段。3 个 grep 必须无输出。
|
||
|
||
**为什么**:一旦 controller / usecase 直接 import 具体实现,测试就需要起 DB 起 Binance;后续替换数据源(CoinGlass)也变成大手术。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
grep -r "repo/webapi/binance\|repo/persistent" internal/controller # 必须无输出
|
||
grep -r "repo/webapi/binance\|repo/persistent" internal/usecase # 必须无输出
|
||
```
|
||
|
||
---
|
||
|
||
## G7. Migration 必须 down/up 互逆
|
||
|
||
**规则**:任何 SQL migration 改动后,本地必须跑:
|
||
```bash
|
||
make migrate-down && make migrate-up
|
||
```
|
||
两步都成功(不报错、不丢表)才能提交。
|
||
|
||
**为什么**:生产回滚是兜底手段,down 脚本错了就回不去。
|
||
|
||
**额外规则**:
|
||
- 新 migration **不可改已发布 migration 的内容**——只能加新文件(编号递增)。
|
||
- 改表结构如果会丢数据(drop column / change type),必须在 PR 里显式说明。
|
||
- 改 hypertable chunk 间隔需要先看 `docs/dev.md` 第 10 章。
|
||
|
||
---
|
||
|
||
## G8. K 线数组下标解析不可破
|
||
|
||
**规则**:`internal/repo/webapi/binance/mapper.go` 中 `mapKline` 的下标解析对应 Binance 官方约定:
|
||
|
||
```
|
||
[0] openTime ms
|
||
[1] open string
|
||
[2] high string
|
||
[3] low string
|
||
[4] close string
|
||
[5] volume string
|
||
[6] closeTime ms
|
||
[7] quoteAssetVolume string
|
||
[8] numberOfTrades int
|
||
[9] takerBuyBaseVolume string
|
||
[10] takerBuyQuoteVolume string
|
||
[11] ignore
|
||
```
|
||
|
||
**禁止**:调整顺序、跳过位置、改用对象解析(Binance 这个接口就是数组)。
|
||
|
||
**为什么**:错一位整张表数据就废了,回滚成本极高。`docs/dev.md` 第 9.2 节有完整说明。
|
||
|
||
**怎么验证**:动了 `mapper.go` 之后跑一次回填,对比 Binance 网页 K 线图同一根的 OHLCV 是否一致。
|
||
|
||
---
|
||
|
||
## G9. 配置项不引入秘钥
|
||
|
||
**规则**:`config/config.yml` 和环境变量列表里不允许出现 `api_key`、`secret`、`token`、`password`(DB 密码除外,且必须从环境变量读、不能 hardcode)。
|
||
|
||
**为什么**:本服务不需要任何 Binance 私有鉴权,也不向外部传秘密。一旦引入会破坏 G1。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
grep -i "api_key\|api_secret\|access_token" config/
|
||
# 期望:无输出
|
||
```
|
||
|
||
---
|
||
|
||
## G10. WebSocket / 实时推送不在 v1 范围
|
||
|
||
**规则**:v1 只用 REST 拉取。不引入 gorilla/websocket、不订阅 `wss://fstream.binance.com`。
|
||
|
||
**为什么**:WS 长连接的可观测性、重连、消息丢失处理是独立工程量。v1 用 REST + cron 已经能满足 Hermes 的"分钟级上下文"需求。要做 WS 必须先写 ADR。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
grep -rn --include="*.go" "websocket\|fstream.binance" .
|
||
# 期望:无输出
|
||
```
|
||
|
||
---
|
||
|
||
## G11. Controller 不编排 derivatives 多源查询
|
||
|
||
**规则**:`internal/controller/**` 不得直接持有或调用 `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository`。这类衍生品历史数据的"查哪些表 + 多源拼装 + 错误降级 → warnings"必须在 `internal/usecase/` 完成,controller 只做参数解析、调用 usecase、返回 JSON。
|
||
|
||
**为什么**:曾经 `/derivatives` handler 一次性编排了 6 处查询、4 个静默吞错的 `_`、硬编码的 limit 和响应结构——HTTP 层在替业务层做决定。要给衍生品加 taker volume、缓存、降级策略、数据质量 warnings 时,应该只动 usecase,不该让 HTTP handler 跟着变厚。
|
||
|
||
**例外**:`KlineRepository` 暂时允许 controller 直查,因为 `/klines` 现在就是 thin query。一旦它要加缓存 / live fallback / interval 聚合,就同样要搬进 usecase。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
|
||
# 期望:无输出
|
||
```
|
||
|
||
---
|
||
|
||
## G12. 指标计算的 float64 边界
|
||
|
||
**规则**:`float64` 在 `internal/usecase/` 下**只允许出现在 `indicator.go` 内部**作为 transient 计算变量;进入 `entity` / 返回上层之前必须 `strconv.FormatFloat(_, 'f', -1, 64)` 转回 string。`internal/usecase/indicator.go` 同时**禁止** import `internal/repo/...` 的任何子包。
|
||
|
||
**为什么**:G2 已经预留"指标计算中间过程可用 float64"的例外,但没有可机械验证的边界。Milestone 6 之后会有更多指标加入,必须把例外明文化、可 grep 化,避免某天 float64 悄悄出现在 entity 字段或被 repo 持久化。决策细节见 `ai/adr/0002-indicator-numeric-boundary.md`。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
# usecase 下除 indicator.go 之外不得出现 float64
|
||
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -v indicator
|
||
# 期望:无输出
|
||
|
||
# indicator.go 不得 import 任何 repo 子包
|
||
grep -n "internal/repo" internal/usecase/indicator.go
|
||
# 期望:无输出
|
||
|
||
# indicator.go 不得 import binance / postgres / pgx
|
||
grep -nE "binance|postgres|pgx" internal/usecase/indicator.go
|
||
# 期望:无输出(注释中的 ADR 引用不算)
|
||
|
||
# entity 字段任何位置不得 float
|
||
grep -rn --include="*.go" "float64\|float32" internal/entity
|
||
# 期望:无输出
|
||
```
|
||
|
||
**例外**:无。如果未来某个指标输入/输出确实需要 decimal 精度(累加型大数据集),先升级 ADR-0002 再改实现。
|
||
|
||
---
|
||
|
||
## G13. docker compose 部署路径不可裸奔
|
||
|
||
**规则**:`docker compose up -d` 的生产/测试路径必须满足:
|
||
|
||
- Postgres 密码从 `.env` 注入,`.env.example` 只能留空占位,不能给 `postgres` 这类弱默认值。
|
||
- `.dockerignore` 必须排除 `.env` 和 `.env.*`,防止 `Dockerfile` 的 `COPY . .` 把 secrets 送进 build context / builder layer。
|
||
- app 容器内端口固定为 8080;宿主机暴露端口只通过 `APP_HOST_PORT` 调整。不要在 compose 中用 `${APP_PORT}` 同时控制容器内监听和宿主机映射。
|
||
- app 必须等待 Postgres healthcheck 通过、migration init container 成功退出后再启动。
|
||
- Docker build 阶段必须显式传入 `GOPROXY` / HTTP(S) proxy build args;`go mod download` 失败必须立刻失败,禁止 `go mod download || true`。
|
||
|
||
**为什么**:这个服务虽然不接 Binance 私钥,但 DB 密码和代理凭据仍可能出现在 `.env`。同时 app 启动前若表不存在,会导致 collector/API 失败;端口变量混用会让健康检查和端口映射分叉。
|
||
|
||
**怎么验证**:
|
||
```bash
|
||
make guard
|
||
|
||
# 部署机额外跑(本地无 docker 时可跳过,但发布前必须跑)
|
||
docker compose config
|
||
docker compose up -d
|
||
docker compose ps
|
||
curl -s http://localhost:${APP_HOST_PORT:-8080}/v1/health
|
||
```
|
||
|
||
**例外**:无。若改为 Kubernetes / Helm / systemd 部署,也必须保留同等约束:secret 不进镜像、migration 先于 app、readiness 可观测、容器内端口稳定。
|
||
|
||
---
|
||
|
||
## 升级守护规则的时机
|
||
|
||
同一类错误第二次出现 → 写一条新规则进来。修改本文件 = 改动了项目契约,需要在 PR 描述里说明原因并 @ 维护者。
|
||
|
||
新增 Hard Stop 规则时,**必须给出可机械验证的命令**——只能靠 review 来执行的规则会被绕过。
|