feat(market): Phase 1 扩展 — 更多 symbol + Bollinger/Vegas + 衍生品信号
- collector.symbols 与 supportedSymbols 加 SOL/BNB/DOGE,env-default 与 README 同步 - entity 追加 TechnicalStructure.Intervals(每周期 Bollinger + Vegas,omitempty 不破坏既有字段)与 DerivativesBundle.Signal - 新增纯解析 usecase derivatives_signal.go:fundingBias 绝对阈值、oiSignal 用 P20 baseline + 价格方向(OI up + 价格 up/down → long/short_building,funding 不参与方向)、lsrRegime 绝对阈值;signal 数据不足走 warnings 进 DataQuality - indicator.go 加 sma/stddev/ema/bollinger/vegas + computeIntervalTechnicals;IndicatorComputer 签名收 klines map - harness 同步:G12 扩入 derivatives_signal.go(含 Makefile G12.5/6 + ADR-0002 适用范围 + project-map / harness-health 更新)
This commit is contained in:
4
Makefile
4
Makefile
@@ -31,9 +31,11 @@ guard:
|
|||||||
@echo "G1 (no signing)..." && ! grep -rqi --include="*.go" "apikey\|x-mbx-apikey\|hmac\|secret_key\|api_secret" .
|
@echo "G1 (no signing)..." && ! grep -rqi --include="*.go" "apikey\|x-mbx-apikey\|hmac\|secret_key\|api_secret" .
|
||||||
@echo "G6 (boundary)..." && ! grep -rq "repo/webapi/binance\|repo/persistent" internal/usecase internal/controller
|
@echo "G6 (boundary)..." && ! grep -rq "repo/webapi/binance\|repo/persistent" internal/usecase internal/controller
|
||||||
@echo "G11 (ctrl derivs)..." && ! grep -rqn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
|
@echo "G11 (ctrl derivs)..." && ! grep -rqn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
|
||||||
@echo "G12.1 (usecase float)..." && ! { grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -v indicator | grep -q . ; }
|
@echo "G12.1 (usecase float)..." && ! { grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal" | grep -q . ; }
|
||||||
@echo "G12.2 (indicator no repo)..." && ! grep -q "internal/repo" internal/usecase/indicator.go
|
@echo "G12.2 (indicator no repo)..." && ! grep -q "internal/repo" internal/usecase/indicator.go
|
||||||
@echo "G12.3 (indicator no DB)..." && ! grep -qE "binance|postgres|pgx" internal/usecase/indicator.go
|
@echo "G12.3 (indicator no DB)..." && ! grep -qE "binance|postgres|pgx" internal/usecase/indicator.go
|
||||||
|
@echo "G12.5 (derivatives_signal no repo)..." && ! grep -q "internal/repo" internal/usecase/derivatives_signal.go
|
||||||
|
@echo "G12.6 (derivatives_signal no DB)..." && ! grep -qE "binance|postgres|pgx" internal/usecase/derivatives_signal.go
|
||||||
@echo "G12.4 (entity no float)..." && ! grep -rqn --include="*.go" "float64\|float32" internal/entity
|
@echo "G12.4 (entity no float)..." && ! grep -rqn --include="*.go" "float64\|float32" internal/entity
|
||||||
@echo "G13.1 (compose requires password)..." && grep -qx 'POSTGRES_PASSWORD=' .env.example
|
@echo "G13.1 (compose requires password)..." && grep -qx 'POSTGRES_PASSWORD=' .env.example
|
||||||
@echo "G13.2 (compose fixed app port)..." && grep -q 'APP_HOST_PORT' docker-compose.yml && grep -q 'APP_PORT: "8080"' docker-compose.yml && ! grep -q '$${APP_PORT' docker-compose.yml
|
@echo "G13.2 (compose fixed app port)..." && grep -q 'APP_HOST_PORT' docker-compose.yml && grep -q 'APP_PORT: "8080"' docker-compose.yml && ! grep -q '$${APP_PORT' docker-compose.yml
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -66,12 +66,11 @@ make migrate-up
|
|||||||
第一次启动时 DB 是空的,直接调 `/v1/market/context` 会触发回源 Binance。建议先回填:
|
第一次启动时 DB 是空的,直接调 `/v1/market/context` 会触发回源 Binance。建议先回填:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make backfill ARGS="--symbol BTCUSDT --interval 15m --limit 500"
|
for s in BTCUSDT ETHUSDT SOLUSDT BNBUSDT DOGEUSDT; do
|
||||||
make backfill ARGS="--symbol BTCUSDT --interval 1h --limit 500"
|
for i in 15m 1h 4h 1d 1w; do
|
||||||
make backfill ARGS="--symbol BTCUSDT --interval 4h --limit 500"
|
make backfill ARGS="--symbol $s --interval $i --limit 500"
|
||||||
make backfill ARGS="--symbol BTCUSDT --interval 1d --limit 500"
|
done
|
||||||
make backfill ARGS="--symbol BTCUSDT --interval 1w --limit 500"
|
done
|
||||||
# ETHUSDT 同上
|
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以指定起止时间做大段回填:
|
也可以指定起止时间做大段回填:
|
||||||
@@ -168,8 +167,9 @@ docker compose logs -f postgres
|
|||||||
启动后 DB 是空的;调用 `/v1/market/context` 会 fallback 到 Binance(带 warnings)。要立刻获得满载历史,进容器跑 backfill:
|
启动后 DB 是空的;调用 `/v1/market/context` 会 fallback 到 Binance(带 warnings)。要立刻获得满载历史,进容器跑 backfill:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec app /app/backfill --symbol BTCUSDT --interval 1h --limit 500
|
for s in BTCUSDT ETHUSDT SOLUSDT BNBUSDT DOGEUSDT; do
|
||||||
docker compose exec app /app/backfill --symbol ETHUSDT --interval 1h --limit 500
|
docker compose exec app /app/backfill --symbol $s --interval 1h --limit 500
|
||||||
|
done
|
||||||
# 其它 interval 同理
|
# 其它 interval 同理
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ postgres:
|
|||||||
|
|
||||||
collector:
|
collector:
|
||||||
enabled: true # 设为 false 可只跑只读 API,不开 cron
|
enabled: true # 设为 false 可只跑只读 API,不开 cron
|
||||||
symbols: [BTCUSDT, ETHUSDT]
|
symbols: [BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, DOGEUSDT]
|
||||||
intervals: [15m, 1h, 4h, 1d, 1w]
|
intervals: [15m, 1h, 4h, 1d, 1w]
|
||||||
default_limit: 500
|
default_limit: 500
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**状态**:Accepted
|
**状态**:Accepted
|
||||||
**日期**:2026-05-24
|
**日期**:2026-05-24
|
||||||
**决策者**:项目维护者
|
**决策者**:项目维护者
|
||||||
**适用范围**:`internal/usecase/indicator.go` 及其后续衍生指标实现
|
**适用范围**:`internal/usecase/indicator.go`、`internal/usecase/derivatives_signal.go` 及其后续同形态的纯解析 usecase 实现
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,10 +33,11 @@ Milestone 6 引入技术指标计算(pivot S/R、percentile range、LSR 穿越
|
|||||||
|
|
||||||
边界规则:
|
边界规则:
|
||||||
|
|
||||||
- `float64` **只**出现在 `internal/usecase/indicator.go` 内部
|
- `float64` **只**出现在 `internal/usecase/indicator.go`、`internal/usecase/derivatives_signal.go` 等"纯解析 usecase 文件"内部
|
||||||
- 进入 `entity.TechnicalLevel` / `entity.TechnicalStructure` 前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 转回 string
|
- 进入任何 entity 字段(TechnicalLevel / TechnicalStructure / DerivativesSignal 等)前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 转回 string;类别型字段(如 `crowded_long`)用 string 字面量
|
||||||
- entity / DTO / persistence / transport 全部保持 string(不变)
|
- entity / DTO / persistence / transport 全部保持 string(不变)
|
||||||
- 任何其它 usecase 文件出现 `float64` 视为违规
|
- 任何其它 usecase 文件出现 `float64` 视为违规
|
||||||
|
- 新增同形态文件时:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6 子规则
|
||||||
|
|
||||||
这是 `ai/risk-guardrails.md` **G2 例外条款**("纯统计/分析(如指标计算的中间过程)可以用 float64,但结果落库或返回 API 时必须转回 string")的具体落地。守卫 G12 把这条例外细化成可机械验证的命令。
|
这是 `ai/risk-guardrails.md` **G2 例外条款**("纯统计/分析(如指标计算的中间过程)可以用 float64,但结果落库或返回 API 时必须转回 string")的具体落地。守卫 G12 把这条例外细化成可机械验证的命令。
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
**现状**:MVP 阶段重点是端到端可用,未补单测覆盖率。
|
**现状**:MVP 阶段重点是端到端可用,未补单测覆盖率。
|
||||||
**影响**:refactor 时风险偏高。
|
**影响**:refactor 时风险偏高。
|
||||||
**升级时机**:Milestone 6 引入指标计算(计算正确性 critical)前,先补 mapper / repo 的 table-driven 测试。
|
**升级时机**:Milestone 6 引入指标计算(计算正确性 critical)前,先补 mapper / repo 的 table-driven 测试。
|
||||||
|
**2026-05-24 更新**:Phase 1 扩展(symbols + Bollinger/Vegas/derivatives signal)落地时,给 `indicator_test.go` 和新建的 `derivatives_signal_test.go` 都铺了表驱动 + 边界用例(含 funding-positive + price-down 的回归用例)。mapper / repo 的覆盖率仍是 partial,留待下一次纯解析工作之外的 PR 再补。
|
||||||
|
|
||||||
### W5. 部分 docs/* 未提交到 git
|
### W5. 部分 docs/* 未提交到 git
|
||||||
**现状**:`docs/dev.md` 是 untracked(git status 显示 `?? docs/`)。
|
**现状**:`docs/dev.md` 是 untracked(git status 显示 `?? docs/`)。
|
||||||
@@ -91,14 +92,15 @@
|
|||||||
## Harness 文件清单(截至本次 commit)
|
## Harness 文件清单(截至本次 commit)
|
||||||
|
|
||||||
```
|
```
|
||||||
AGENTS.md ← AI agent 速查
|
AGENTS.md ← AI agent 速查
|
||||||
README.md ← 用户向使用文档
|
README.md ← 用户向使用文档
|
||||||
docs/dev.md ← 完整设计文档(源头)
|
docs/dev.md ← 完整设计文档(源头)
|
||||||
ai/project-map.md ← 仓库结构与扩展点
|
ai/project-map.md ← 仓库结构与扩展点
|
||||||
ai/risk-guardrails.md ← G1-G13 守卫规则
|
ai/risk-guardrails.md ← G1-G13 守卫规则
|
||||||
ai/adr/0001-architecture-foundations.md ← 9 条架构基础决策
|
ai/adr/0001-architecture-foundations.md ← 9 条架构基础决策
|
||||||
ai/task-templates.md ← 6 种任务模板
|
ai/adr/0002-indicator-numeric-boundary.md ← float64 transient 边界 + G12 落地
|
||||||
ai/harness-health.md ← 本文件
|
ai/task-templates.md ← 6 种任务模板
|
||||||
|
ai/harness-health.md ← 本文件
|
||||||
```
|
```
|
||||||
|
|
||||||
CLAUDE.md / GEMINI.md / .cursor/*.md 等其他 AI 工具的入口文件——**暂不需要**,因为这些工具都会读 AGENTS.md。如未来要差异化对待,再分裂。
|
CLAUDE.md / GEMINI.md / .cursor/*.md 等其他 AI 工具的入口文件——**暂不需要**,因为这些工具都会读 AGENTS.md。如未来要差异化对待,再分裂。
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e
|
|||||||
- **价格/成交量字段全部 `string`**,避免 float64 精度问题。落库时 PG 转 `NUMERIC(36,18)`。
|
- **价格/成交量字段全部 `string`**,避免 float64 精度问题。落库时 PG 转 `NUMERIC(36,18)`。
|
||||||
- `Kline` 有 `IsClosed bool` 字段——未收线 K 线 (`close_time > now()`) 由 mapper 自动判定。
|
- `Kline` 有 `IsClosed bool` 字段——未收线 K 线 (`close_time > now()`) 由 mapper 自动判定。
|
||||||
- `LongShortRatio` 有常量 `RatioTypeGlobalAccount` / `RatioTypeTopTraderPosition` / `RatioTypeTopTraderAccount`。
|
- `LongShortRatio` 有常量 `RatioTypeGlobalAccount` / `RatioTypeTopTraderPosition` / `RatioTypeTopTraderAccount`。
|
||||||
|
- `market_context.go` 还承载技术结构与衍生品信号的输出类型:`TechnicalStructure`(含 `Intervals map[string]IntervalTechnicals`,新增字段对消费方完全可选)、`Bollinger`、`Vegas`、`DerivativesSignal`(`fundingBias` / `oiSignal` / `lsrRegime`)。所有数值仍是 string。
|
||||||
|
|
||||||
**不依赖**:fiber、pgx、binance、任何外部库。可以从这里 import 到任何其他层。
|
**不依赖**:fiber、pgx、binance、任何外部库。可以从这里 import 到任何其他层。
|
||||||
|
|
||||||
@@ -81,8 +82,11 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e
|
|||||||
- `MarketDataProvider`(K线、ticker24h)
|
- `MarketDataProvider`(K线、ticker24h)
|
||||||
- `DerivativesProvider`(funding/OI/多空比/taker volume 共 7 个方法)
|
- `DerivativesProvider`(funding/OI/多空比/taker volume 共 7 个方法)
|
||||||
- `KlineRepository` / `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository`
|
- `KlineRepository` / `FundingRepository` / `OpenInterestRepository` / `LongShortRatioRepository` / `TakerVolumeRepository`
|
||||||
- `market_context.go`:聚合 `/v1/market/context`。errgroup 并发拉 snapshot/funding/OI,DB K 线不足 200 根回源 Binance 补,异步写回。
|
- `IndicatorComputer`(pivot S/R + range + LSR 中位 + 每周期 Bollinger/Vegas)
|
||||||
|
- `DerivativesSignalComputer`(funding/OI/LSR → fundingBias/oiSignal/lsrRegime;返回 warnings 由 caller 合并到 DataQuality)
|
||||||
|
- `market_context.go`:聚合 `/v1/market/context`。errgroup 并发拉 snapshot/funding/OI,DB K 线不足 200 根回源 Binance 补,异步写回。`supportedSymbols` 白名单当前为 BTC/ETH/SOL/BNB/DOGE USDT 五个。
|
||||||
- `market_query.go`:`/v1/market/derivatives` 后端。`GetDerivatives(symbol, period)` 并发拉 current funding/OI + 串行查 funding/OI/多空比历史,错误降级为 warnings 返回。
|
- `market_query.go`:`/v1/market/derivatives` 后端。`GetDerivatives(symbol, period)` 并发拉 current funding/OI + 串行查 funding/OI/多空比历史,错误降级为 warnings 返回。
|
||||||
|
- `indicator.go`、`derivatives_signal.go`:**纯解析 usecase**,零 repo 依赖。允许 transient `float64`(G12 例外),输出转回 string 写入 entity。新增同形态文件时必须同步扩 Makefile G12.1 白名单与 G12.5/6 子规则,参考 `ai/adr/0002-indicator-numeric-boundary.md`。
|
||||||
|
|
||||||
**约束**(grep 可验证):
|
**约束**(grep 可验证):
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -195,25 +195,29 @@ grep -rn "FundingRepo\|OIRepo\|LSRepo\|TakerRepo" internal/controller
|
|||||||
|
|
||||||
## G12. 指标计算的 float64 边界
|
## G12. 指标计算的 float64 边界
|
||||||
|
|
||||||
**规则**:`float64` 在 `internal/usecase/` 下**只允许出现在 `indicator.go` 内部**作为 transient 计算变量;进入 `entity` / 返回上层之前必须 `strconv.FormatFloat(_, 'f', -1, 64)` 转回 string。`internal/usecase/indicator.go` 同时**禁止** import `internal/repo/...` 的任何子包。
|
**规则**:`float64` 在 `internal/usecase/` 下**只允许出现在纯解析 usecase 文件**(当前为 `indicator.go` 与 `derivatives_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`。
|
**为什么**:G2 已经预留"指标计算中间过程可用 float64"的例外,但没有可机械验证的边界。Milestone 6 之后会有更多指标和衍生品信号加入,必须把例外明文化、可 grep 化,避免某天 float64 悄悄出现在 entity 字段或被 repo 持久化。决策细节见 `ai/adr/0002-indicator-numeric-boundary.md`。
|
||||||
|
|
||||||
**怎么验证**:
|
**新增同形态文件时**:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6 子规则(参考 `derivatives_signal.go` 接入的方式),并把文件名加入本规则的"当前为"括注。
|
||||||
|
|
||||||
|
**怎么验证**(由 `make guard` 自动跑):
|
||||||
```bash
|
```bash
|
||||||
# usecase 下除 indicator.go 之外不得出现 float64
|
# G12.1:usecase 下除白名单文件外不得出现 float64
|
||||||
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -v indicator
|
grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -vE "indicator|derivatives_signal"
|
||||||
# 期望:无输出
|
# 期望:无输出
|
||||||
|
|
||||||
# indicator.go 不得 import 任何 repo 子包
|
# G12.2/G12.3:indicator.go 不得 import repo 子包,也不得 import binance/postgres/pgx
|
||||||
grep -n "internal/repo" internal/usecase/indicator.go
|
grep -n "internal/repo" internal/usecase/indicator.go
|
||||||
# 期望:无输出
|
|
||||||
|
|
||||||
# indicator.go 不得 import binance / postgres / pgx
|
|
||||||
grep -nE "binance|postgres|pgx" internal/usecase/indicator.go
|
grep -nE "binance|postgres|pgx" internal/usecase/indicator.go
|
||||||
# 期望:无输出(注释中的 ADR 引用不算)
|
# 期望:无输出(注释中的 ADR 引用不算)
|
||||||
|
|
||||||
# entity 字段任何位置不得 float
|
# G12.5/G12.6:derivatives_signal.go 同上
|
||||||
|
grep -n "internal/repo" internal/usecase/derivatives_signal.go
|
||||||
|
grep -nE "binance|postgres|pgx" internal/usecase/derivatives_signal.go
|
||||||
|
# 期望:无输出
|
||||||
|
|
||||||
|
# G12.4:entity 字段任何位置不得 float
|
||||||
grep -rn --include="*.go" "float64\|float32" internal/entity
|
grep -rn --include="*.go" "float64\|float32" internal/entity
|
||||||
# 期望:无输出
|
# 期望:无输出
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type Postgres struct {
|
|||||||
|
|
||||||
type Collector struct {
|
type Collector struct {
|
||||||
Enabled bool `yaml:"enabled" env-default:"true"`
|
Enabled bool `yaml:"enabled" env-default:"true"`
|
||||||
Symbols []string `yaml:"symbols" env-default:"BTCUSDT,ETHUSDT"`
|
Symbols []string `yaml:"symbols" env-default:"BTCUSDT,ETHUSDT,SOLUSDT,BNBUSDT,DOGEUSDT"`
|
||||||
Intervals []string `yaml:"intervals" env-default:"15m,1h,4h,1d,1w"`
|
Intervals []string `yaml:"intervals" env-default:"15m,1h,4h,1d,1w"`
|
||||||
DefaultLimit int `yaml:"default_limit" env-default:"500"`
|
DefaultLimit int `yaml:"default_limit" env-default:"500"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ collector:
|
|||||||
symbols:
|
symbols:
|
||||||
- BTCUSDT
|
- BTCUSDT
|
||||||
- ETHUSDT
|
- ETHUSDT
|
||||||
|
- SOLUSDT
|
||||||
|
- BNBUSDT
|
||||||
|
- DOGEUSDT
|
||||||
intervals:
|
intervals:
|
||||||
- 15m
|
- 15m
|
||||||
- 1h
|
- 1h
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func Run(cfg *config.Config, log *slog.Logger) error {
|
|||||||
oiRepo,
|
oiRepo,
|
||||||
lsRepo,
|
lsRepo,
|
||||||
usecase.NewIndicatorUsecase(),
|
usecase.NewIndicatorUsecase(),
|
||||||
|
usecase.NewDerivativesSignalUsecase(),
|
||||||
log,
|
log,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ type DerivativesBundle struct {
|
|||||||
OpenInterest OpenInterestBundle `json:"openInterest"`
|
OpenInterest OpenInterestBundle `json:"openInterest"`
|
||||||
LongShortRatio LongShortBundle `json:"longShortRatio"`
|
LongShortRatio LongShortBundle `json:"longShortRatio"`
|
||||||
TakerBuySellVolume []TakerBuySellVolume `json:"takerBuySellVolume"`
|
TakerBuySellVolume []TakerBuySellVolume `json:"takerBuySellVolume"`
|
||||||
|
Signal *DerivativesSignal `json:"signal,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DerivativesSignal 把 funding / OI / LSR 的原始数字翻译成可读语义,
|
||||||
|
// 供上游策略引擎直接消费。任一子字段为空字符串表示数据不足以判定。
|
||||||
|
type DerivativesSignal struct {
|
||||||
|
FundingBias string `json:"fundingBias,omitempty"` // crowded_long / crowded_short / neutral
|
||||||
|
OISignal string `json:"oiSignal,omitempty"` // long_building / short_building / deleveraging / stable
|
||||||
|
LSRRegime string `json:"lsrRegime,omitempty"` // retail_long_heavy / retail_short_heavy / balanced
|
||||||
}
|
}
|
||||||
|
|
||||||
type FundingBundle struct {
|
type FundingBundle struct {
|
||||||
@@ -33,11 +42,36 @@ type LongShortBundle struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TechnicalStructure struct {
|
type TechnicalStructure struct {
|
||||||
Support []TechnicalLevel `json:"support"`
|
Support []TechnicalLevel `json:"support"`
|
||||||
Resistance []TechnicalLevel `json:"resistance"`
|
Resistance []TechnicalLevel `json:"resistance"`
|
||||||
RangeHigh *string `json:"rangeHigh"`
|
RangeHigh *string `json:"rangeHigh"`
|
||||||
RangeLow *string `json:"rangeLow"`
|
RangeLow *string `json:"rangeLow"`
|
||||||
LongShortLine *string `json:"longShortLine"`
|
LongShortLine *string `json:"longShortLine"`
|
||||||
|
Intervals map[string]IntervalTechnicals `json:"intervals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntervalTechnicals struct {
|
||||||
|
Bollinger *Bollinger `json:"bollinger,omitempty"`
|
||||||
|
Vegas *Vegas `json:"vegas,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bollinger 是 20 周期、2 倍标准差的布林带。
|
||||||
|
// BandwidthPct = (upper-lower)/mid * 100,Squeeze 取 tight/normal/expanded。
|
||||||
|
type Bollinger struct {
|
||||||
|
Mid string `json:"mid"`
|
||||||
|
Upper string `json:"upper"`
|
||||||
|
Lower string `json:"lower"`
|
||||||
|
BandwidthPct string `json:"bandwidthPct"`
|
||||||
|
Squeeze string `json:"squeeze"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vegas 通道:EMA12/144/169 三条线 + 趋势判定。
|
||||||
|
// Trend: bull / bear / range,按三条线的堆叠顺序判定。
|
||||||
|
type Vegas struct {
|
||||||
|
EMA12 string `json:"ema12"`
|
||||||
|
EMA144 string `json:"ema144"`
|
||||||
|
EMA169 string `json:"ema169"`
|
||||||
|
Trend string `json:"trend"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TechnicalLevel struct {
|
type TechnicalLevel struct {
|
||||||
|
|||||||
215
internal/usecase/derivatives_signal.go
Normal file
215
internal/usecase/derivatives_signal.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// Package usecase: derivatives_signal.go 把 funding/OI/LSR 三个原始
|
||||||
|
// 衍生品维度翻译成上游策略引擎可直接消费的语义字段。
|
||||||
|
//
|
||||||
|
// 与 indicator.go 同款约束:纯函数 usecase,零 repo 依赖,G1/G12 守卫。
|
||||||
|
// 价格 / 比例值在文件内部允许 transient float64,不写回 entity。
|
||||||
|
//
|
||||||
|
// 判定策略(混合):
|
||||||
|
// - FundingBias 用绝对阈值(funding rate 本身是百分比,跨 symbol 可比)
|
||||||
|
// - OISignal 用滚动分位(OI 数量级跨 symbol 差异大,需归一化)
|
||||||
|
// + 价格方向(OI up + price up/down 判 long/short_building,
|
||||||
|
// funding 不参与,避免价格下跌但 funding 仍为正时误判)
|
||||||
|
// - LSRRegime 用绝对阈值(比例值,可比)
|
||||||
|
//
|
||||||
|
// 数据不足的子判定通过 warnings 显式告知调用方,调用方应把 warnings
|
||||||
|
// 合并到 DataQuality.Warnings 让消费端看到。
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"cryptoHermes/internal/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FundingBias: 单期 funding rate(8h 结算)超过 ±0.05% 视为拥挤
|
||||||
|
fundingCrowdedAbs = 0.0005
|
||||||
|
|
||||||
|
// OISignal: 用最近 N 根 OI 历史的 P20 做基线,
|
||||||
|
// |latest / p20 - 1| < oiStableBandPct 视为 stable;
|
||||||
|
// 历史不足 oiMinHistoryPoints 根则不出该字段。
|
||||||
|
oiRollingP = 0.20
|
||||||
|
oiStableBandPct = 0.02
|
||||||
|
oiMinHistoryPoints = 24
|
||||||
|
|
||||||
|
// OI 在涨时判 long/short_building 需要价格方向:
|
||||||
|
// 取末根 close 与 oiPriceLookback 根之前的 close 比较。
|
||||||
|
// 1h K 线 × 24 ≈ 与 OI baseline 同窗口(最近 24h)。
|
||||||
|
oiPriceLookback = 24
|
||||||
|
|
||||||
|
// LSRRegime: 全局多空账户比阈值
|
||||||
|
lsrRetailHi = 1.5
|
||||||
|
lsrRetailLo = 0.67
|
||||||
|
)
|
||||||
|
|
||||||
|
// DerivativesSignalUsecase 实现 DerivativesSignalComputer。
|
||||||
|
type DerivativesSignalUsecase struct{}
|
||||||
|
|
||||||
|
// NewDerivativesSignalUsecase 构造器;无依赖。
|
||||||
|
func NewDerivativesSignalUsecase() *DerivativesSignalUsecase {
|
||||||
|
return &DerivativesSignalUsecase{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute 把三个维度合并成一个 *DerivativesSignal + warnings。
|
||||||
|
// 三个子判定相互独立:任何一项数据不足,对应字段留空 + 一条 warning,
|
||||||
|
// 不影响其它字段。全部都判不出来时返回 nil signal。
|
||||||
|
func (u *DerivativesSignalUsecase) Compute(
|
||||||
|
currentFunding *entity.FundingRate,
|
||||||
|
oiHistory []entity.OpenInterest,
|
||||||
|
globalLSR []entity.LongShortRatio,
|
||||||
|
primaryKlines []entity.Kline,
|
||||||
|
) (*entity.DerivativesSignal, []string) {
|
||||||
|
var warnings []string
|
||||||
|
|
||||||
|
fb, fbWarn := fundingBias(currentFunding)
|
||||||
|
if fbWarn != "" {
|
||||||
|
warnings = append(warnings, fbWarn)
|
||||||
|
}
|
||||||
|
oi, oiWarn := oiSignal(oiHistory, primaryKlines)
|
||||||
|
if oiWarn != "" {
|
||||||
|
warnings = append(warnings, oiWarn)
|
||||||
|
}
|
||||||
|
lsr, lsrWarn := lsrRegime(globalLSR)
|
||||||
|
if lsrWarn != "" {
|
||||||
|
warnings = append(warnings, lsrWarn)
|
||||||
|
}
|
||||||
|
if fb == "" && oi == "" && lsr == "" {
|
||||||
|
return nil, warnings
|
||||||
|
}
|
||||||
|
return &entity.DerivativesSignal{
|
||||||
|
FundingBias: fb,
|
||||||
|
OISignal: oi,
|
||||||
|
LSRRegime: lsr,
|
||||||
|
}, warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// fundingBias 用绝对阈值判定当前 funding 是否"拥挤"。
|
||||||
|
// 解析失败 / 缺数据 → ("", warning)。
|
||||||
|
func fundingBias(f *entity.FundingRate) (string, string) {
|
||||||
|
if f == nil {
|
||||||
|
return "", "signal.fundingBias: current funding unavailable"
|
||||||
|
}
|
||||||
|
v, ok := parseFloat(f.FundingRate)
|
||||||
|
if !ok {
|
||||||
|
return "", "signal.fundingBias: current funding rate unparseable"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v > fundingCrowdedAbs:
|
||||||
|
return "crowded_long", ""
|
||||||
|
case v < -fundingCrowdedAbs:
|
||||||
|
return "crowded_short", ""
|
||||||
|
default:
|
||||||
|
return "neutral", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oiSignal 用 OI 历史 P20 做基线,与最新值比较:
|
||||||
|
// - |pct| < oiStableBandPct → stable
|
||||||
|
// - pct < 0 → deleveraging
|
||||||
|
// - pct > 0 → 看 1h 价格方向(latest close vs ~24h 前):
|
||||||
|
// price up → long_building;price down → short_building;
|
||||||
|
// 价格 K 线不足 oiPriceLookback+1 根则留空 + warning(不靠 funding 兜底,
|
||||||
|
// funding 是拥挤度而非新仓方向,会误判)。
|
||||||
|
//
|
||||||
|
// 历史不足 oiMinHistoryPoints 根或全部 parse 失败 → ("", warning)。
|
||||||
|
func oiSignal(history []entity.OpenInterest, primaryKlines []entity.Kline) (string, string) {
|
||||||
|
if len(history) < oiMinHistoryPoints {
|
||||||
|
return "", fmt.Sprintf("signal.oiSignal: OI history insufficient (have %d, need %d)", len(history), oiMinHistoryPoints)
|
||||||
|
}
|
||||||
|
values := make([]float64, 0, len(history))
|
||||||
|
for _, p := range history {
|
||||||
|
if v, ok := parseFloat(p.OpenInterest); ok && v > 0 {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) < oiMinHistoryPoints {
|
||||||
|
return "", fmt.Sprintf("signal.oiSignal: OI history mostly unparseable (parsed %d of %d)", len(values), len(history))
|
||||||
|
}
|
||||||
|
latest := values[len(values)-1]
|
||||||
|
sorted := make([]float64, len(values))
|
||||||
|
copy(sorted, values)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
baseline := percentile(sorted, oiRollingP)
|
||||||
|
if baseline <= 0 {
|
||||||
|
return "", "signal.oiSignal: OI baseline non-positive"
|
||||||
|
}
|
||||||
|
pct := (latest - baseline) / baseline
|
||||||
|
if math.Abs(pct) < oiStableBandPct {
|
||||||
|
return "stable", ""
|
||||||
|
}
|
||||||
|
if pct < 0 {
|
||||||
|
return "deleveraging", ""
|
||||||
|
}
|
||||||
|
// OI 在涨:用价格方向判定多头建仓 / 空头建仓
|
||||||
|
dir, warn := priceDirection(primaryKlines, oiPriceLookback)
|
||||||
|
switch dir {
|
||||||
|
case "up":
|
||||||
|
return "long_building", ""
|
||||||
|
case "down":
|
||||||
|
return "short_building", ""
|
||||||
|
default:
|
||||||
|
return "", warn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// priceDirection 比较末根 close 与回看 lookback 根之前的 close。
|
||||||
|
// 返回 "up" / "down" / ""(数据不足或解析失败 → 带 warning)。
|
||||||
|
// flat(严格相等)视为 down 的退化情况——但这种概率几乎为零,
|
||||||
|
// 与其引入 stable 分支让 OI 信号变三态,不如归到 down 简化语义。
|
||||||
|
func priceDirection(klines []entity.Kline, lookback int) (string, string) {
|
||||||
|
need := lookback + 1
|
||||||
|
if len(klines) < need {
|
||||||
|
return "", fmt.Sprintf("signal.oiSignal: primary klines insufficient for price direction (have %d, need %d)", len(klines), need)
|
||||||
|
}
|
||||||
|
latestIdx := len(klines) - 1
|
||||||
|
priorIdx := latestIdx - lookback
|
||||||
|
latest, ok1 := parsePrice(klines[latestIdx].Close)
|
||||||
|
prior, ok2 := parsePrice(klines[priorIdx].Close)
|
||||||
|
if !ok1 || !ok2 || prior == 0 {
|
||||||
|
return "", "signal.oiSignal: primary klines close unparseable"
|
||||||
|
}
|
||||||
|
if latest > prior {
|
||||||
|
return "up", ""
|
||||||
|
}
|
||||||
|
return "down", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsrRegime 用最新一根全局多空账户比判定散户拥挤方向。
|
||||||
|
// 切片为空或末尾解析失败 → ("", warning)。
|
||||||
|
func lsrRegime(ls []entity.LongShortRatio) (string, string) {
|
||||||
|
if len(ls) == 0 {
|
||||||
|
return "", "signal.lsrRegime: global LSR empty"
|
||||||
|
}
|
||||||
|
last := ls[len(ls)-1]
|
||||||
|
v, ok := parseFloat(last.LongShortRatio)
|
||||||
|
if !ok {
|
||||||
|
return "", "signal.lsrRegime: latest LSR unparseable"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case v > lsrRetailHi:
|
||||||
|
return "retail_long_heavy", ""
|
||||||
|
case v < lsrRetailLo:
|
||||||
|
return "retail_short_heavy", ""
|
||||||
|
default:
|
||||||
|
return "balanced", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFloat 是 derivatives_signal 内部用的 float 解析。
|
||||||
|
// 比 parsePrice 多接受负数(funding 可负);NaN/Inf/空串视为失败。
|
||||||
|
func parseFloat(s string) (float64, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
280
internal/usecase/derivatives_signal_test.go
Normal file
280
internal/usecase/derivatives_signal_test.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cryptoHermes/internal/entity"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mkOI(idx int, v string) entity.OpenInterest {
|
||||||
|
return entity.OpenInterest{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Period: "1h",
|
||||||
|
Timestamp: int64(idx) * 3600 * 1000,
|
||||||
|
OpenInterest: v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkLSRAt(idx int, ratio string) entity.LongShortRatio {
|
||||||
|
return entity.LongShortRatio{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Period: "1h",
|
||||||
|
RatioType: entity.RatioTypeGlobalAccount,
|
||||||
|
Timestamp: int64(idx) * 3600 * 1000,
|
||||||
|
LongShortRatio: ratio,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// klinesWithLastClose 构造 n 根 1h K 线,全部 close = baseClose,
|
||||||
|
// 最后一根 close = lastClose(用来精准控制价格方向)。
|
||||||
|
// 价格趋势靠"末根 vs 末根-lookback 根"判定,中间不影响。
|
||||||
|
func klinesWithLastClose(n int, baseClose, lastClose float64) []entity.Kline {
|
||||||
|
out := make([]entity.Kline, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
c := baseClose
|
||||||
|
if i == n-1 {
|
||||||
|
c = lastClose
|
||||||
|
}
|
||||||
|
out[i] = mkKline(int64(i),
|
||||||
|
strconv.FormatFloat(c, 'f', -1, 64),
|
||||||
|
strconv.FormatFloat(c+0.5, 'f', -1, 64),
|
||||||
|
strconv.FormatFloat(c-0.5, 'f', -1, 64),
|
||||||
|
strconv.FormatFloat(c, 'f', -1, 64),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FundingBias ------------------------------------------------------
|
||||||
|
|
||||||
|
func TestFundingBias_Cases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rate string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"crowded_long", "0.001", "crowded_long"},
|
||||||
|
{"crowded_short", "-0.001", "crowded_short"},
|
||||||
|
{"neutral_zero", "0", "neutral"},
|
||||||
|
{"neutral_small_pos", "0.0001", "neutral"},
|
||||||
|
{"neutral_small_neg", "-0.0001", "neutral"},
|
||||||
|
{"boundary_high", "0.0005", "neutral"}, // 严格 >
|
||||||
|
{"boundary_low", "-0.0005", "neutral"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, warn := fundingBias(&entity.FundingRate{FundingRate: tc.rate})
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFundingBias_NilAndBadInput(t *testing.T) {
|
||||||
|
got, warn := fundingBias(nil)
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
require.NotEmpty(t, warn)
|
||||||
|
|
||||||
|
got, warn = fundingBias(&entity.FundingRate{FundingRate: ""})
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
require.NotEmpty(t, warn)
|
||||||
|
|
||||||
|
got, warn = fundingBias(&entity.FundingRate{FundingRate: "not-a-number"})
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
require.NotEmpty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OISignal ---------------------------------------------------------
|
||||||
|
|
||||||
|
func makeOIHistory(values []float64) []entity.OpenInterest {
|
||||||
|
out := make([]entity.OpenInterest, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
out[i] = mkOI(i, formatPrice(v))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 25+ 根 K 线方便 priceDirection 拿到(需要 lookback+1 = 25 根)。
|
||||||
|
func sufficientKlines(latestClose float64) []entity.Kline {
|
||||||
|
return klinesWithLastClose(25, 100, latestClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_InsufficientHistory(t *testing.T) {
|
||||||
|
values := make([]float64, 23) // 23 < oiMinHistoryPoints(24)
|
||||||
|
for i := range values {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100))
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
require.Contains(t, warn, "OI history insufficient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_Stable(t *testing.T) {
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := range values {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100))
|
||||||
|
require.Equal(t, "stable", got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_Deleveraging(t *testing.T) {
|
||||||
|
// 前 25 根 1000,最后 5 根降到 800:OI 在跌,不看价格
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
for i := 25; i < 30; i++ {
|
||||||
|
values[i] = 800
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(100))
|
||||||
|
require.Equal(t, "deleveraging", got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_LongBuilding_PriceUp(t *testing.T) {
|
||||||
|
// OI 涨 + 价格涨(末根 110 > base 100)→ long_building
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
for i := 25; i < 30; i++ {
|
||||||
|
values[i] = 1200
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(110))
|
||||||
|
require.Equal(t, "long_building", got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_ShortBuilding_PriceDown(t *testing.T) {
|
||||||
|
// OI 涨 + 价格跌(末根 90 < base 100)→ short_building
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
for i := 25; i < 30; i++ {
|
||||||
|
values[i] = 1200
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(90))
|
||||||
|
require.Equal(t, "short_building", got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_OIUp_KlinesInsufficient(t *testing.T) {
|
||||||
|
// OI 涨但价格 K 线只有 10 根,无法判定方向
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
for i := 25; i < 30; i++ {
|
||||||
|
values[i] = 1200
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), klinesWithLastClose(10, 100, 110))
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
require.Contains(t, warn, "primary klines insufficient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOISignal_FundingIgnoredForDirection(t *testing.T) {
|
||||||
|
// 关键回归用例:funding 为正但价格下跌
|
||||||
|
// 旧逻辑会误判 long_building,新逻辑应判 short_building
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
for i := 25; i < 30; i++ {
|
||||||
|
values[i] = 1200
|
||||||
|
}
|
||||||
|
got, warn := oiSignal(makeOIHistory(values), sufficientKlines(90))
|
||||||
|
require.Equal(t, "short_building", got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LSRRegime --------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLSRRegime_Cases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
last string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"retail_long_heavy", "1.6", "retail_long_heavy"},
|
||||||
|
{"retail_short_heavy", "0.5", "retail_short_heavy"},
|
||||||
|
{"balanced_mid", "1.0", "balanced"},
|
||||||
|
{"balanced_near_hi", "1.5", "balanced"},
|
||||||
|
{"balanced_near_lo", "0.67", "balanced"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, warn := lsrRegime([]entity.LongShortRatio{mkLSRAt(0, tc.last)})
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLSRRegime_UsesLatest(t *testing.T) {
|
||||||
|
got, warn := lsrRegime([]entity.LongShortRatio{
|
||||||
|
mkLSRAt(0, "1.6"),
|
||||||
|
mkLSRAt(1, "0.5"),
|
||||||
|
})
|
||||||
|
require.Equal(t, "retail_short_heavy", got)
|
||||||
|
require.Empty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLSRRegime_Empty(t *testing.T) {
|
||||||
|
got, warn := lsrRegime(nil)
|
||||||
|
require.Equal(t, "", got)
|
||||||
|
require.NotEmpty(t, warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Compute(编排) ---------------------------------------------------
|
||||||
|
|
||||||
|
func TestCompute_AllDataPresent(t *testing.T) {
|
||||||
|
u := NewDerivativesSignalUsecase()
|
||||||
|
values := make([]float64, 30)
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
values[i] = 1000
|
||||||
|
}
|
||||||
|
for i := 25; i < 30; i++ {
|
||||||
|
values[i] = 1200
|
||||||
|
}
|
||||||
|
got, warnings := u.Compute(
|
||||||
|
&entity.FundingRate{FundingRate: "0.001"},
|
||||||
|
makeOIHistory(values),
|
||||||
|
[]entity.LongShortRatio{mkLSRAt(0, "1.6")},
|
||||||
|
sufficientKlines(110),
|
||||||
|
)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
require.Equal(t, "crowded_long", got.FundingBias)
|
||||||
|
require.Equal(t, "long_building", got.OISignal)
|
||||||
|
require.Equal(t, "retail_long_heavy", got.LSRRegime)
|
||||||
|
require.Empty(t, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompute_AllEmpty_ReturnsNilWithWarnings(t *testing.T) {
|
||||||
|
u := NewDerivativesSignalUsecase()
|
||||||
|
got, warnings := u.Compute(nil, nil, nil, nil)
|
||||||
|
require.Nil(t, got)
|
||||||
|
// 三个子判定都失败,应有 3 条 warning
|
||||||
|
require.Len(t, warnings, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompute_PartialData(t *testing.T) {
|
||||||
|
u := NewDerivativesSignalUsecase()
|
||||||
|
got, warnings := u.Compute(
|
||||||
|
&entity.FundingRate{FundingRate: "0.001"},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
require.Equal(t, "crowded_long", got.FundingBias)
|
||||||
|
require.Equal(t, "", got.OISignal)
|
||||||
|
require.Equal(t, "", got.LSRRegime)
|
||||||
|
// OI + LSR 缺数据,应有 2 条 warning
|
||||||
|
require.Len(t, warnings, 2)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
// - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类
|
// - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类
|
||||||
// - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值
|
// - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值
|
||||||
// - LongShortLine:近 N 根 LSR 穿越 1.0 的价位中位数
|
// - LongShortLine:近 N 根 LSR 穿越 1.0 的价位中位数
|
||||||
|
// - Bollinger(per interval):SMA20 + 2σ
|
||||||
|
// - Vegas(per interval):EMA12/144/169 三线 + 趋势判定
|
||||||
//
|
//
|
||||||
// 数值边界:本文件内部允许 transient float64,但输出到 entity 前
|
// 数值边界:本文件内部允许 transient float64,但输出到 entity 前
|
||||||
// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md
|
// 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md
|
||||||
@@ -38,6 +40,17 @@ const (
|
|||||||
// TechnicalLevel.Source 值
|
// TechnicalLevel.Source 值
|
||||||
sourceSupportPivot = "pivot_low"
|
sourceSupportPivot = "pivot_low"
|
||||||
sourceResistancePivot = "pivot_high"
|
sourceResistancePivot = "pivot_high"
|
||||||
|
|
||||||
|
// Bollinger:周期 20、倍数 2、Squeeze 阈值按 BandwidthPct(百分比)
|
||||||
|
bbPeriod = 20
|
||||||
|
bbStdMult = 2.0
|
||||||
|
bbSqueezeTight = 4.0
|
||||||
|
bbSqueezeExpanded = 12.0
|
||||||
|
|
||||||
|
// Vegas 通道 EMA 周期
|
||||||
|
vegasFast = 12
|
||||||
|
vegasMid = 144
|
||||||
|
vegasSlow = 169
|
||||||
)
|
)
|
||||||
|
|
||||||
// IndicatorUsecase 实现 IndicatorComputer 接口。
|
// IndicatorUsecase 实现 IndicatorComputer 接口。
|
||||||
@@ -54,14 +67,18 @@ type pivotPoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compute 是 IndicatorComputer 的唯一对外方法。
|
// Compute 是 IndicatorComputer 的唯一对外方法。
|
||||||
// klines 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。
|
// klinesByInterval 每个 value 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。
|
||||||
|
// primaryInterval 指定 support/resistance/range/longShortLine 用哪个周期;
|
||||||
|
// 其它周期(包括 primary 自己)都会参与 Intervals 里的 Bollinger / Vegas。
|
||||||
// longShort 同样升序;可能为空。
|
// longShort 同样升序;可能为空。
|
||||||
func (u *IndicatorUsecase) Compute(
|
func (u *IndicatorUsecase) Compute(
|
||||||
klines []entity.Kline,
|
klinesByInterval map[string][]entity.Kline,
|
||||||
|
primaryInterval string,
|
||||||
longShort []entity.LongShortRatio,
|
longShort []entity.LongShortRatio,
|
||||||
) entity.TechnicalStructure {
|
) entity.TechnicalStructure {
|
||||||
highs := pivotHighs(klines, pivotLeft, pivotRight)
|
primary := klinesByInterval[primaryInterval]
|
||||||
lows := pivotLows(klines, pivotLeft, pivotRight)
|
highs := pivotHighs(primary, pivotLeft, pivotRight)
|
||||||
|
lows := pivotLows(primary, pivotLeft, pivotRight)
|
||||||
resistance := clusterLevels(highs, clusterPctThreshold, sourceResistancePivot)
|
resistance := clusterLevels(highs, clusterPctThreshold, sourceResistancePivot)
|
||||||
support := clusterLevels(lows, clusterPctThreshold, sourceSupportPivot)
|
support := clusterLevels(lows, clusterPctThreshold, sourceSupportPivot)
|
||||||
if support == nil {
|
if support == nil {
|
||||||
@@ -70,17 +87,45 @@ func (u *IndicatorUsecase) Compute(
|
|||||||
if resistance == nil {
|
if resistance == nil {
|
||||||
resistance = []entity.TechnicalLevel{}
|
resistance = []entity.TechnicalLevel{}
|
||||||
}
|
}
|
||||||
hi, lo := rangeHighLow(klines)
|
hi, lo := rangeHighLow(primary)
|
||||||
lsLine := longShortCrossings(longShort, klines, longShortCrossKeep)
|
lsLine := longShortCrossings(longShort, primary, longShortCrossKeep)
|
||||||
|
|
||||||
|
intervals := make(map[string]entity.IntervalTechnicals, len(klinesByInterval))
|
||||||
|
for iv, ks := range klinesByInterval {
|
||||||
|
intervals[iv] = computeIntervalTechnicals(ks)
|
||||||
|
}
|
||||||
|
|
||||||
return entity.TechnicalStructure{
|
return entity.TechnicalStructure{
|
||||||
Support: support,
|
Support: support,
|
||||||
Resistance: resistance,
|
Resistance: resistance,
|
||||||
RangeHigh: hi,
|
RangeHigh: hi,
|
||||||
RangeLow: lo,
|
RangeLow: lo,
|
||||||
LongShortLine: lsLine,
|
LongShortLine: lsLine,
|
||||||
|
Intervals: intervals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeIntervalTechnicals 在单周期 K 线上算 Bollinger 与 Vegas。
|
||||||
|
// 任一项数据不足则对应字段为 nil。
|
||||||
|
func computeIntervalTechnicals(klines []entity.Kline) entity.IntervalTechnicals {
|
||||||
|
closes := parseCloses(klines)
|
||||||
|
return entity.IntervalTechnicals{
|
||||||
|
Bollinger: bollinger(closes),
|
||||||
|
Vegas: vegas(closes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCloses 提取 close 价位,跳过解析失败的根。
|
||||||
|
func parseCloses(klines []entity.Kline) []float64 {
|
||||||
|
out := make([]float64, 0, len(klines))
|
||||||
|
for _, k := range klines {
|
||||||
|
if v, ok := parsePrice(k.Close); ok {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// parsePrice 用 strconv 把 string 价格解析成 float64。
|
// parsePrice 用 strconv 把 string 价格解析成 float64。
|
||||||
// 失败(空 / 非数字 / 含空格)返回 ok=false,调用者应跳过该值不 panic。
|
// 失败(空 / 非数字 / 含空格)返回 ok=false,调用者应跳过该值不 panic。
|
||||||
// NaN 与 ±Inf 也视为解析失败。
|
// NaN 与 ±Inf 也视为解析失败。
|
||||||
@@ -377,3 +422,111 @@ func medianFloat(xs []float64) float64 {
|
|||||||
}
|
}
|
||||||
return (xs[n/2-1] + xs[n/2]) / 2
|
return (xs[n/2-1] + xs[n/2]) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sma 计算切片末尾 period 个值的简单移动均值。
|
||||||
|
// len < period 返回 ok=false。
|
||||||
|
func sma(values []float64, period int) (float64, bool) {
|
||||||
|
n := len(values)
|
||||||
|
if period <= 0 || n < period {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sum := 0.0
|
||||||
|
for _, v := range values[n-period:] {
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
return sum / float64(period), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stddevPop 计算切片末尾 period 个值相对 mean 的总体标准差(除以 N)。
|
||||||
|
// 金融惯例(TradingView / TA-Lib):布林带用 population stddev,不用 sample。
|
||||||
|
func stddevPop(values []float64, period int, mean float64) (float64, bool) {
|
||||||
|
n := len(values)
|
||||||
|
if period <= 0 || n < period {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sumSq := 0.0
|
||||||
|
for _, v := range values[n-period:] {
|
||||||
|
d := v - mean
|
||||||
|
sumSq += d * d
|
||||||
|
}
|
||||||
|
return math.Sqrt(sumSq / float64(period)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// emaLast 计算时间序列末尾的 EMA(period)。
|
||||||
|
// 初值用前 period 根的 SMA(标准做法),之后用 α=2/(period+1) 递推。
|
||||||
|
// len < period 返回 ok=false。
|
||||||
|
func emaLast(values []float64, period int) (float64, bool) {
|
||||||
|
n := len(values)
|
||||||
|
if period <= 0 || n < period {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
seed := 0.0
|
||||||
|
for _, v := range values[:period] {
|
||||||
|
seed += v
|
||||||
|
}
|
||||||
|
ema := seed / float64(period)
|
||||||
|
if n == period {
|
||||||
|
return ema, true
|
||||||
|
}
|
||||||
|
alpha := 2.0 / float64(period+1)
|
||||||
|
for _, v := range values[period:] {
|
||||||
|
ema = alpha*v + (1-alpha)*ema
|
||||||
|
}
|
||||||
|
return ema, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// bollinger 在 closes 上算 20 周期、2σ 布林带。
|
||||||
|
// 数据不足 bbPeriod 根或 mid 为 0 时返回 nil。
|
||||||
|
// Squeeze 按 BandwidthPct(百分比)分档:< 4 tight,> 12 expanded,否则 normal。
|
||||||
|
func bollinger(closes []float64) *entity.Bollinger {
|
||||||
|
mid, ok := sma(closes, bbPeriod)
|
||||||
|
if !ok || mid == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sd, ok := stddevPop(closes, bbPeriod, mid)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
upper := mid + bbStdMult*sd
|
||||||
|
lower := mid - bbStdMult*sd
|
||||||
|
bw := (upper - lower) / mid * 100
|
||||||
|
squeeze := "normal"
|
||||||
|
switch {
|
||||||
|
case bw < bbSqueezeTight:
|
||||||
|
squeeze = "tight"
|
||||||
|
case bw > bbSqueezeExpanded:
|
||||||
|
squeeze = "expanded"
|
||||||
|
}
|
||||||
|
return &entity.Bollinger{
|
||||||
|
Mid: formatPrice(mid),
|
||||||
|
Upper: formatPrice(upper),
|
||||||
|
Lower: formatPrice(lower),
|
||||||
|
BandwidthPct: formatPrice(bw),
|
||||||
|
Squeeze: squeeze,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vegas 在 closes 上算 EMA12/144/169 + 趋势判定。
|
||||||
|
// 任一 EMA 计算失败(长度不足 169)则返回 nil。
|
||||||
|
// Trend:fast > mid > slow → bull;fast < mid < slow → bear;其余 range。
|
||||||
|
func vegas(closes []float64) *entity.Vegas {
|
||||||
|
fast, ok1 := emaLast(closes, vegasFast)
|
||||||
|
mid, ok2 := emaLast(closes, vegasMid)
|
||||||
|
slow, ok3 := emaLast(closes, vegasSlow)
|
||||||
|
if !ok1 || !ok2 || !ok3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trend := "range"
|
||||||
|
switch {
|
||||||
|
case fast > mid && mid > slow:
|
||||||
|
trend = "bull"
|
||||||
|
case fast < mid && mid < slow:
|
||||||
|
trend = "bear"
|
||||||
|
}
|
||||||
|
return &entity.Vegas{
|
||||||
|
EMA12: formatPrice(fast),
|
||||||
|
EMA144: formatPrice(mid),
|
||||||
|
EMA169: formatPrice(slow),
|
||||||
|
Trend: trend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ func TestNearestKlineIndex(t *testing.T) {
|
|||||||
|
|
||||||
func TestCompute_EmptyInputs(t *testing.T) {
|
func TestCompute_EmptyInputs(t *testing.T) {
|
||||||
u := NewIndicatorUsecase()
|
u := NewIndicatorUsecase()
|
||||||
out := u.Compute(nil, nil)
|
out := u.Compute(nil, "1h", nil)
|
||||||
require.NotNil(t, out.Support)
|
require.NotNil(t, out.Support)
|
||||||
require.NotNil(t, out.Resistance)
|
require.NotNil(t, out.Resistance)
|
||||||
require.Empty(t, out.Support)
|
require.Empty(t, out.Support)
|
||||||
@@ -499,6 +499,8 @@ func TestCompute_EmptyInputs(t *testing.T) {
|
|||||||
require.Nil(t, out.RangeHigh)
|
require.Nil(t, out.RangeHigh)
|
||||||
require.Nil(t, out.RangeLow)
|
require.Nil(t, out.RangeLow)
|
||||||
require.Nil(t, out.LongShortLine)
|
require.Nil(t, out.LongShortLine)
|
||||||
|
require.NotNil(t, out.Intervals)
|
||||||
|
require.Empty(t, out.Intervals)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompute_SmokeWithVShape(t *testing.T) {
|
func TestCompute_SmokeWithVShape(t *testing.T) {
|
||||||
@@ -519,10 +521,113 @@ func TestCompute_SmokeWithVShape(t *testing.T) {
|
|||||||
strconv.FormatFloat(p+0.25, 'f', -1, 64),
|
strconv.FormatFloat(p+0.25, 'f', -1, 64),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
out := u.Compute(klines, nil)
|
out := u.Compute(map[string][]entity.Kline{"1h": klines}, "1h", nil)
|
||||||
require.Len(t, out.Support, 1)
|
require.Len(t, out.Support, 1)
|
||||||
require.Empty(t, out.Resistance)
|
require.Empty(t, out.Resistance)
|
||||||
require.NotNil(t, out.RangeHigh)
|
require.NotNil(t, out.RangeHigh)
|
||||||
require.NotNil(t, out.RangeLow)
|
require.NotNil(t, out.RangeLow)
|
||||||
require.Nil(t, out.LongShortLine, "no LSR data → nil")
|
require.Nil(t, out.LongShortLine, "no LSR data → nil")
|
||||||
|
require.Contains(t, out.Intervals, "1h")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Bollinger / Vegas -------------------------------------------------
|
||||||
|
|
||||||
|
// flatCloses 生成 n 根价位恒为 v 的 closes(用于 Squeeze=tight 验证)。
|
||||||
|
func flatCloses(n int, v float64) []float64 {
|
||||||
|
out := make([]float64, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ascCloses 生成等差递增 closes:base, base+step, ...
|
||||||
|
func ascCloses(n int, base, step float64) []float64 {
|
||||||
|
out := make([]float64, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = base + float64(i)*step
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBollinger_FlatSeries_Tight(t *testing.T) {
|
||||||
|
bb := bollinger(flatCloses(20, 100))
|
||||||
|
require.NotNil(t, bb)
|
||||||
|
require.Equal(t, "100", bb.Mid)
|
||||||
|
require.Equal(t, "100", bb.Upper)
|
||||||
|
require.Equal(t, "100", bb.Lower)
|
||||||
|
require.Equal(t, "0", bb.BandwidthPct)
|
||||||
|
require.Equal(t, "tight", bb.Squeeze)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBollinger_AscendingSeries_Expanded(t *testing.T) {
|
||||||
|
// 0..29 等差,末 20 根 mid=19.5、σ ≈ 5.77、bw ≈ 118%
|
||||||
|
bb := bollinger(ascCloses(30, 0, 1))
|
||||||
|
require.NotNil(t, bb)
|
||||||
|
require.Equal(t, "expanded", bb.Squeeze)
|
||||||
|
mid, _ := strconv.ParseFloat(bb.Mid, 64)
|
||||||
|
require.InDelta(t, 19.5, mid, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBollinger_InsufficientData(t *testing.T) {
|
||||||
|
require.Nil(t, bollinger(flatCloses(19, 100)))
|
||||||
|
require.Nil(t, bollinger(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVegas_BullishStack(t *testing.T) {
|
||||||
|
// 上升序列 → EMA12 > EMA144 > EMA169,趋势 bull
|
||||||
|
v := vegas(ascCloses(200, 100, 1))
|
||||||
|
require.NotNil(t, v)
|
||||||
|
e12, _ := strconv.ParseFloat(v.EMA12, 64)
|
||||||
|
e144, _ := strconv.ParseFloat(v.EMA144, 64)
|
||||||
|
e169, _ := strconv.ParseFloat(v.EMA169, 64)
|
||||||
|
require.Greater(t, e12, e144)
|
||||||
|
require.Greater(t, e144, e169)
|
||||||
|
require.Equal(t, "bull", v.Trend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVegas_BearishStack(t *testing.T) {
|
||||||
|
// 下降序列
|
||||||
|
v := vegas(ascCloses(200, 300, -1))
|
||||||
|
require.NotNil(t, v)
|
||||||
|
require.Equal(t, "bear", v.Trend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVegas_FlatRange(t *testing.T) {
|
||||||
|
// 平盘:三条线相等
|
||||||
|
v := vegas(flatCloses(200, 100))
|
||||||
|
require.NotNil(t, v)
|
||||||
|
require.Equal(t, "range", v.Trend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVegas_InsufficientData(t *testing.T) {
|
||||||
|
require.Nil(t, vegas(ascCloses(168, 100, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompute_IntervalsPopulated(t *testing.T) {
|
||||||
|
u := NewIndicatorUsecase()
|
||||||
|
// 给 1h(25 根) 与 4h(200 根) 喂上升序列
|
||||||
|
mk := func(n int) []entity.Kline {
|
||||||
|
out := make([]entity.Kline, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
p := 100 + float64(i)
|
||||||
|
out[i] = mkKline(int64(i),
|
||||||
|
strconv.FormatFloat(p, 'f', -1, 64),
|
||||||
|
strconv.FormatFloat(p+0.5, 'f', -1, 64),
|
||||||
|
strconv.FormatFloat(p, 'f', -1, 64),
|
||||||
|
strconv.FormatFloat(p+0.25, 'f', -1, 64),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out := u.Compute(map[string][]entity.Kline{
|
||||||
|
"1h": mk(25),
|
||||||
|
"4h": mk(200),
|
||||||
|
}, "1h", nil)
|
||||||
|
require.Contains(t, out.Intervals, "1h")
|
||||||
|
require.Contains(t, out.Intervals, "4h")
|
||||||
|
require.NotNil(t, out.Intervals["1h"].Bollinger, "25 closes 够算 BB")
|
||||||
|
require.Nil(t, out.Intervals["1h"].Vegas, "25 closes 不够算 EMA169")
|
||||||
|
require.NotNil(t, out.Intervals["4h"].Bollinger)
|
||||||
|
require.NotNil(t, out.Intervals["4h"].Vegas)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var supportedSymbols = map[string]bool{
|
var supportedSymbols = map[string]bool{
|
||||||
"BTCUSDT": true,
|
"BTCUSDT": true,
|
||||||
"ETHUSDT": true,
|
"ETHUSDT": true,
|
||||||
|
"SOLUSDT": true,
|
||||||
|
"BNBUSDT": true,
|
||||||
|
"DOGEUSDT": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportedIntervals = []string{"15m", "1h", "4h", "1d", "1w"}
|
var supportedIntervals = []string{"15m", "1h", "4h", "1d", "1w"}
|
||||||
@@ -39,7 +42,8 @@ type MarketContextUsecase struct {
|
|||||||
oiRepo OpenInterestRepository
|
oiRepo OpenInterestRepository
|
||||||
lsRepo LongShortRatioRepository
|
lsRepo LongShortRatioRepository
|
||||||
|
|
||||||
indicator IndicatorComputer
|
indicator IndicatorComputer
|
||||||
|
derivSignal DerivativesSignalComputer
|
||||||
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,7 @@ func NewMarketContextUsecase(
|
|||||||
oiRepo OpenInterestRepository,
|
oiRepo OpenInterestRepository,
|
||||||
lsRepo LongShortRatioRepository,
|
lsRepo LongShortRatioRepository,
|
||||||
indicator IndicatorComputer,
|
indicator IndicatorComputer,
|
||||||
|
derivSignal DerivativesSignalComputer,
|
||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
) *MarketContextUsecase {
|
) *MarketContextUsecase {
|
||||||
return &MarketContextUsecase{
|
return &MarketContextUsecase{
|
||||||
@@ -62,6 +67,7 @@ func NewMarketContextUsecase(
|
|||||||
oiRepo: oiRepo,
|
oiRepo: oiRepo,
|
||||||
lsRepo: lsRepo,
|
lsRepo: lsRepo,
|
||||||
indicator: indicator,
|
indicator: indicator,
|
||||||
|
derivSignal: derivSignal,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,27 +173,36 @@ func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entit
|
|||||||
addWarn("top trader position long/short query failed: " + err.Error())
|
addWarn("top trader position long/short query failed: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
derivBundle := entity.DerivativesBundle{
|
||||||
|
Funding: entity.FundingBundle{
|
||||||
|
Current: currentFund,
|
||||||
|
History: fundingHist,
|
||||||
|
},
|
||||||
|
OpenInterest: entity.OpenInterestBundle{
|
||||||
|
Current: currentOI,
|
||||||
|
History: oiHist,
|
||||||
|
},
|
||||||
|
LongShortRatio: entity.LongShortBundle{
|
||||||
|
Global: globalLS,
|
||||||
|
TopTraderPosition: topLS,
|
||||||
|
},
|
||||||
|
TakerBuySellVolume: nil,
|
||||||
|
}
|
||||||
|
if u.derivSignal != nil {
|
||||||
|
signal, sigWarnings := u.derivSignal.Compute(currentFund, oiHist, globalLS, klines[derivativePeriod])
|
||||||
|
derivBundle.Signal = signal
|
||||||
|
for _, w := range sigWarnings {
|
||||||
|
addWarn(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
out := &entity.MarketContext{
|
out := &entity.MarketContext{
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
GeneratedAt: time.Now().UnixMilli(),
|
GeneratedAt: time.Now().UnixMilli(),
|
||||||
Snapshot: snapshot,
|
Snapshot: snapshot,
|
||||||
Klines: klines,
|
Klines: klines,
|
||||||
Derivatives: entity.DerivativesBundle{
|
Derivatives: derivBundle,
|
||||||
Funding: entity.FundingBundle{
|
Technical: u.indicator.Compute(klines, derivativePeriod, globalLS),
|
||||||
Current: currentFund,
|
|
||||||
History: fundingHist,
|
|
||||||
},
|
|
||||||
OpenInterest: entity.OpenInterestBundle{
|
|
||||||
Current: currentOI,
|
|
||||||
History: oiHist,
|
|
||||||
},
|
|
||||||
LongShortRatio: entity.LongShortBundle{
|
|
||||||
Global: globalLS,
|
|
||||||
TopTraderPosition: topLS,
|
|
||||||
},
|
|
||||||
TakerBuySellVolume: nil,
|
|
||||||
},
|
|
||||||
Technical: u.indicator.Compute(klines[derivativePeriod], globalLS),
|
|
||||||
DataQuality: entity.DataQuality{
|
DataQuality: entity.DataQuality{
|
||||||
Source: "binance",
|
Source: "binance",
|
||||||
Warnings: warnings,
|
Warnings: warnings,
|
||||||
|
|||||||
@@ -58,7 +58,34 @@ type TakerVolumeRepository interface {
|
|||||||
// 过 kline 与 long-short ratio,复用结果即可,二次注入仓库引用会
|
// 过 kline 与 long-short ratio,复用结果即可,二次注入仓库引用会
|
||||||
// 重复 IO。
|
// 重复 IO。
|
||||||
//
|
//
|
||||||
|
// klinesByInterval 是按周期分组的 K 线 map(每周期一份升序切片);
|
||||||
|
// primaryInterval 指定哪个周期参与 support/resistance/range/longShortLine
|
||||||
|
// 的计算(其它周期只参与 Intervals 多周期指标如 Bollinger / Vegas)。
|
||||||
// 实现可见 internal/usecase/indicator.go。
|
// 实现可见 internal/usecase/indicator.go。
|
||||||
type IndicatorComputer interface {
|
type IndicatorComputer interface {
|
||||||
Compute(klines []entity.Kline, longShort []entity.LongShortRatio) entity.TechnicalStructure
|
Compute(
|
||||||
|
klinesByInterval map[string][]entity.Kline,
|
||||||
|
primaryInterval string,
|
||||||
|
longShort []entity.LongShortRatio,
|
||||||
|
) entity.TechnicalStructure
|
||||||
|
}
|
||||||
|
|
||||||
|
// DerivativesSignalComputer 把 funding/OI/LSR 的原始数据翻译成
|
||||||
|
// 可读语义(crowded_long、long_building 之类)。
|
||||||
|
//
|
||||||
|
// 与 IndicatorComputer 一样是纯函数式接口、零 repo 依赖。
|
||||||
|
//
|
||||||
|
// primaryKlines 是用来给 OI 信号做"价格方向"判定的:OI 在涨时,配合
|
||||||
|
// 价格涨 → long_building,价格跌 → short_building(funding 不参与 OI
|
||||||
|
// 判定,避免价格下跌但 funding 仍为正时被误判)。
|
||||||
|
//
|
||||||
|
// 返回 warnings 用来告诉调用方"哪一项数据不够、对应字段为什么留空",
|
||||||
|
// 由调用方合并到 DataQuality.Warnings。signal 整体不可判时返回 nil。
|
||||||
|
type DerivativesSignalComputer interface {
|
||||||
|
Compute(
|
||||||
|
currentFunding *entity.FundingRate,
|
||||||
|
oiHistory []entity.OpenInterest,
|
||||||
|
globalLSR []entity.LongShortRatio,
|
||||||
|
primaryKlines []entity.Kline,
|
||||||
|
) (*entity.DerivativesSignal, []string)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user