From fa769331c294cd30760a96700bc59ab7252c3bfc Mon Sep 17 00:00:00 2001 From: dela Date: Sun, 24 May 2026 23:19:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(market):=20Phase=201=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=20=E2=80=94=20=E6=9B=B4=E5=A4=9A=20symbol=20+=20Bollinger/Vega?= =?UTF-8?q?s=20+=20=E8=A1=8D=E7=94=9F=E5=93=81=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 更新) --- Makefile | 4 +- README.md | 18 +- ai/adr/0002-indicator-numeric-boundary.md | 7 +- ai/harness-health.md | 18 +- ai/project-map.md | 6 +- ai/risk-guardrails.md | 24 +- config/config.go | 2 +- config/config.yml | 3 + internal/app/app.go | 1 + internal/entity/market_context.go | 44 ++- internal/usecase/derivatives_signal.go | 215 +++++++++++++++ internal/usecase/derivatives_signal_test.go | 280 ++++++++++++++++++++ internal/usecase/indicator.go | 165 +++++++++++- internal/usecase/indicator_test.go | 109 +++++++- internal/usecase/market_context.go | 53 ++-- internal/usecase/ports.go | 29 +- 16 files changed, 912 insertions(+), 66 deletions(-) create mode 100644 internal/usecase/derivatives_signal.go create mode 100644 internal/usecase/derivatives_signal_test.go diff --git a/Makefile b/Makefile index c45f759..a62e4f7 100644 --- a/Makefile +++ b/Makefile @@ -31,9 +31,11 @@ guard: @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 "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.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 "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 diff --git a/README.md b/README.md index 0672119..a28bfc9 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,11 @@ make migrate-up 第一次启动时 DB 是空的,直接调 `/v1/market/context` 会触发回源 Binance。建议先回填: ```bash -make backfill ARGS="--symbol BTCUSDT --interval 15m --limit 500" -make backfill ARGS="--symbol BTCUSDT --interval 1h --limit 500" -make backfill ARGS="--symbol BTCUSDT --interval 4h --limit 500" -make backfill ARGS="--symbol BTCUSDT --interval 1d --limit 500" -make backfill ARGS="--symbol BTCUSDT --interval 1w --limit 500" -# ETHUSDT 同上 +for s in BTCUSDT ETHUSDT SOLUSDT BNBUSDT DOGEUSDT; do + for i in 15m 1h 4h 1d 1w; do + make backfill ARGS="--symbol $s --interval $i --limit 500" + done +done ``` 也可以指定起止时间做大段回填: @@ -168,8 +167,9 @@ docker compose logs -f postgres 启动后 DB 是空的;调用 `/v1/market/context` 会 fallback 到 Binance(带 warnings)。要立刻获得满载历史,进容器跑 backfill: ```bash -docker compose exec app /app/backfill --symbol BTCUSDT --interval 1h --limit 500 -docker compose exec app /app/backfill --symbol ETHUSDT --interval 1h --limit 500 +for s in BTCUSDT ETHUSDT SOLUSDT BNBUSDT DOGEUSDT; do + docker compose exec app /app/backfill --symbol $s --interval 1h --limit 500 +done # 其它 interval 同理 ``` @@ -222,7 +222,7 @@ postgres: collector: enabled: true # 设为 false 可只跑只读 API,不开 cron - symbols: [BTCUSDT, ETHUSDT] + symbols: [BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, DOGEUSDT] intervals: [15m, 1h, 4h, 1d, 1w] default_limit: 500 ``` diff --git a/ai/adr/0002-indicator-numeric-boundary.md b/ai/adr/0002-indicator-numeric-boundary.md index 5ebda06..e8fb674 100644 --- a/ai/adr/0002-indicator-numeric-boundary.md +++ b/ai/adr/0002-indicator-numeric-boundary.md @@ -3,7 +3,7 @@ **状态**:Accepted **日期**: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` 内部 -- 进入 `entity.TechnicalLevel` / `entity.TechnicalStructure` 前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 转回 string +- `float64` **只**出现在 `internal/usecase/indicator.go`、`internal/usecase/derivatives_signal.go` 等"纯解析 usecase 文件"内部 +- 进入任何 entity 字段(TechnicalLevel / TechnicalStructure / DerivativesSignal 等)前必须 `strconv.FormatFloat(f, 'f', -1, 64)` 转回 string;类别型字段(如 `crowded_long`)用 string 字面量 - entity / DTO / persistence / transport 全部保持 string(不变) - 任何其它 usecase 文件出现 `float64` 视为违规 +- 新增同形态文件时:必须同步扩 Makefile 的 G12.1 白名单与 G12.5/6 子规则 这是 `ai/risk-guardrails.md` **G2 例外条款**("纯统计/分析(如指标计算的中间过程)可以用 float64,但结果落库或返回 API 时必须转回 string")的具体落地。守卫 G12 把这条例外细化成可机械验证的命令。 diff --git a/ai/harness-health.md b/ai/harness-health.md index 5264efa..72e49b5 100644 --- a/ai/harness-health.md +++ b/ai/harness-health.md @@ -60,6 +60,7 @@ **现状**:MVP 阶段重点是端到端可用,未补单测覆盖率。 **影响**:refactor 时风险偏高。 **升级时机**: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 **现状**:`docs/dev.md` 是 untracked(git status 显示 `?? docs/`)。 @@ -91,14 +92,15 @@ ## Harness 文件清单(截至本次 commit) ``` -AGENTS.md ← AI agent 速查 -README.md ← 用户向使用文档 -docs/dev.md ← 完整设计文档(源头) -ai/project-map.md ← 仓库结构与扩展点 -ai/risk-guardrails.md ← G1-G13 守卫规则 -ai/adr/0001-architecture-foundations.md ← 9 条架构基础决策 -ai/task-templates.md ← 6 种任务模板 -ai/harness-health.md ← 本文件 +AGENTS.md ← AI agent 速查 +README.md ← 用户向使用文档 +docs/dev.md ← 完整设计文档(源头) +ai/project-map.md ← 仓库结构与扩展点 +ai/risk-guardrails.md ← G1-G13 守卫规则 +ai/adr/0001-architecture-foundations.md ← 9 条架构基础决策 +ai/adr/0002-indicator-numeric-boundary.md ← float64 transient 边界 + G12 落地 +ai/task-templates.md ← 6 种任务模板 +ai/harness-health.md ← 本文件 ``` CLAUDE.md / GEMINI.md / .cursor/*.md 等其他 AI 工具的入口文件——**暂不需要**,因为这些工具都会读 AGENTS.md。如未来要差异化对待,再分裂。 diff --git a/ai/project-map.md b/ai/project-map.md index b6eb5c7..8c685e5 100644 --- a/ai/project-map.md +++ b/ai/project-map.md @@ -72,6 +72,7 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e - **价格/成交量字段全部 `string`**,避免 float64 精度问题。落库时 PG 转 `NUMERIC(36,18)`。 - `Kline` 有 `IsClosed bool` 字段——未收线 K 线 (`close_time > now()`) 由 mapper 自动判定。 - `LongShortRatio` 有常量 `RatioTypeGlobalAccount` / `RatioTypeTopTraderPosition` / `RatioTypeTopTraderAccount`。 +- `market_context.go` 还承载技术结构与衍生品信号的输出类型:`TechnicalStructure`(含 `Intervals map[string]IntervalTechnicals`,新增字段对消费方完全可选)、`Bollinger`、`Vegas`、`DerivativesSignal`(`fundingBias` / `oiSignal` / `lsrRegime`)。所有数值仍是 string。 **不依赖**:fiber、pgx、binance、任何外部库。可以从这里 import 到任何其他层。 @@ -81,8 +82,11 @@ Clean Architecture 四层。**依赖方向单向:`controller → usecase → e - `MarketDataProvider`(K线、ticker24h) - `DerivativesProvider`(funding/OI/多空比/taker volume 共 7 个方法) - `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 返回。 +- `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 可验证): ```bash diff --git a/ai/risk-guardrails.md b/ai/risk-guardrails.md index 6a043cf..c6282f6 100644 --- a/ai/risk-guardrails.md +++ b/ai/risk-guardrails.md @@ -195,25 +195,29 @@ 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/...` 的任何子包。 +**规则**:`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 -# usecase 下除 indicator.go 之外不得出现 float64 -grep -rn --include="*.go" "float64\|float32" internal/usecase | grep -v indicator +# G12.1:usecase 下除白名单文件外不得出现 float64 +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 -# 期望:无输出 - -# indicator.go 不得 import binance / postgres / pgx grep -nE "binance|postgres|pgx" internal/usecase/indicator.go # 期望:无输出(注释中的 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 # 期望:无输出 ``` diff --git a/config/config.go b/config/config.go index d291f5a..a067b9f 100644 --- a/config/config.go +++ b/config/config.go @@ -42,7 +42,7 @@ type Postgres struct { type Collector struct { 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"` DefaultLimit int `yaml:"default_limit" env-default:"500"` } diff --git a/config/config.yml b/config/config.yml index 5c9f084..6e368e0 100644 --- a/config/config.yml +++ b/config/config.yml @@ -25,6 +25,9 @@ collector: symbols: - BTCUSDT - ETHUSDT + - SOLUSDT + - BNBUSDT + - DOGEUSDT intervals: - 15m - 1h diff --git a/internal/app/app.go b/internal/app/app.go index 8e7be73..731281c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -56,6 +56,7 @@ func Run(cfg *config.Config, log *slog.Logger) error { oiRepo, lsRepo, usecase.NewIndicatorUsecase(), + usecase.NewDerivativesSignalUsecase(), log, ) diff --git a/internal/entity/market_context.go b/internal/entity/market_context.go index 601e611..7ae01e1 100644 --- a/internal/entity/market_context.go +++ b/internal/entity/market_context.go @@ -15,6 +15,15 @@ type DerivativesBundle struct { OpenInterest OpenInterestBundle `json:"openInterest"` LongShortRatio LongShortBundle `json:"longShortRatio"` 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 { @@ -33,11 +42,36 @@ type LongShortBundle struct { } type TechnicalStructure struct { - Support []TechnicalLevel `json:"support"` - Resistance []TechnicalLevel `json:"resistance"` - RangeHigh *string `json:"rangeHigh"` - RangeLow *string `json:"rangeLow"` - LongShortLine *string `json:"longShortLine"` + Support []TechnicalLevel `json:"support"` + Resistance []TechnicalLevel `json:"resistance"` + RangeHigh *string `json:"rangeHigh"` + RangeLow *string `json:"rangeLow"` + 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 { diff --git a/internal/usecase/derivatives_signal.go b/internal/usecase/derivatives_signal.go new file mode 100644 index 0000000..7524b1a --- /dev/null +++ b/internal/usecase/derivatives_signal.go @@ -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 +} diff --git a/internal/usecase/derivatives_signal_test.go b/internal/usecase/derivatives_signal_test.go new file mode 100644 index 0000000..0c7f911 --- /dev/null +++ b/internal/usecase/derivatives_signal_test.go @@ -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) +} diff --git a/internal/usecase/indicator.go b/internal/usecase/indicator.go index 06f0a7e..d1b2571 100644 --- a/internal/usecase/indicator.go +++ b/internal/usecase/indicator.go @@ -4,6 +4,8 @@ // - Support / Resistance:pivot 局部极值 + 0.3% 价格聚类 // - RangeHigh / RangeLow:近 N 根 P95(High) / P5(Low) 线性插值 // - LongShortLine:近 N 根 LSR 穿越 1.0 的价位中位数 +// - Bollinger(per interval):SMA20 + 2σ +// - Vegas(per interval):EMA12/144/169 三线 + 趋势判定 // // 数值边界:本文件内部允许 transient float64,但输出到 entity 前 // 必须 FormatFloat 转回 string。详见 ai/adr/0002-indicator-numeric-boundary.md @@ -38,6 +40,17 @@ const ( // TechnicalLevel.Source 值 sourceSupportPivot = "pivot_low" 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 接口。 @@ -54,14 +67,18 @@ type pivotPoint struct { } // Compute 是 IndicatorComputer 的唯一对外方法。 -// klines 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。 +// klinesByInterval 每个 value 必须按 OpenTime 升序排好(KlineRepository.FindRecent 已保证)。 +// primaryInterval 指定 support/resistance/range/longShortLine 用哪个周期; +// 其它周期(包括 primary 自己)都会参与 Intervals 里的 Bollinger / Vegas。 // longShort 同样升序;可能为空。 func (u *IndicatorUsecase) Compute( - klines []entity.Kline, + klinesByInterval map[string][]entity.Kline, + primaryInterval string, longShort []entity.LongShortRatio, ) entity.TechnicalStructure { - highs := pivotHighs(klines, pivotLeft, pivotRight) - lows := pivotLows(klines, pivotLeft, pivotRight) + primary := klinesByInterval[primaryInterval] + highs := pivotHighs(primary, pivotLeft, pivotRight) + lows := pivotLows(primary, pivotLeft, pivotRight) resistance := clusterLevels(highs, clusterPctThreshold, sourceResistancePivot) support := clusterLevels(lows, clusterPctThreshold, sourceSupportPivot) if support == nil { @@ -70,17 +87,45 @@ func (u *IndicatorUsecase) Compute( if resistance == nil { resistance = []entity.TechnicalLevel{} } - hi, lo := rangeHighLow(klines) - lsLine := longShortCrossings(longShort, klines, longShortCrossKeep) + hi, lo := rangeHighLow(primary) + 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{ Support: support, Resistance: resistance, RangeHigh: hi, RangeLow: lo, 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。 // 失败(空 / 非数字 / 含空格)返回 ok=false,调用者应跳过该值不 panic。 // NaN 与 ±Inf 也视为解析失败。 @@ -377,3 +422,111 @@ func medianFloat(xs []float64) float64 { } 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, + } +} diff --git a/internal/usecase/indicator_test.go b/internal/usecase/indicator_test.go index 36aae45..0801a0a 100644 --- a/internal/usecase/indicator_test.go +++ b/internal/usecase/indicator_test.go @@ -491,7 +491,7 @@ func TestNearestKlineIndex(t *testing.T) { func TestCompute_EmptyInputs(t *testing.T) { u := NewIndicatorUsecase() - out := u.Compute(nil, nil) + out := u.Compute(nil, "1h", nil) require.NotNil(t, out.Support) require.NotNil(t, out.Resistance) require.Empty(t, out.Support) @@ -499,6 +499,8 @@ func TestCompute_EmptyInputs(t *testing.T) { require.Nil(t, out.RangeHigh) require.Nil(t, out.RangeLow) require.Nil(t, out.LongShortLine) + require.NotNil(t, out.Intervals) + require.Empty(t, out.Intervals) } func TestCompute_SmokeWithVShape(t *testing.T) { @@ -519,10 +521,113 @@ func TestCompute_SmokeWithVShape(t *testing.T) { 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.Empty(t, out.Resistance) require.NotNil(t, out.RangeHigh) require.NotNil(t, out.RangeLow) 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) } diff --git a/internal/usecase/market_context.go b/internal/usecase/market_context.go index 72604c6..8cf230d 100644 --- a/internal/usecase/market_context.go +++ b/internal/usecase/market_context.go @@ -14,8 +14,11 @@ import ( ) var supportedSymbols = map[string]bool{ - "BTCUSDT": true, - "ETHUSDT": true, + "BTCUSDT": true, + "ETHUSDT": true, + "SOLUSDT": true, + "BNBUSDT": true, + "DOGEUSDT": true, } var supportedIntervals = []string{"15m", "1h", "4h", "1d", "1w"} @@ -39,7 +42,8 @@ type MarketContextUsecase struct { oiRepo OpenInterestRepository lsRepo LongShortRatioRepository - indicator IndicatorComputer + indicator IndicatorComputer + derivSignal DerivativesSignalComputer log *slog.Logger } @@ -52,6 +56,7 @@ func NewMarketContextUsecase( oiRepo OpenInterestRepository, lsRepo LongShortRatioRepository, indicator IndicatorComputer, + derivSignal DerivativesSignalComputer, log *slog.Logger, ) *MarketContextUsecase { return &MarketContextUsecase{ @@ -62,6 +67,7 @@ func NewMarketContextUsecase( oiRepo: oiRepo, lsRepo: lsRepo, indicator: indicator, + derivSignal: derivSignal, 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()) } + 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{ Symbol: symbol, GeneratedAt: time.Now().UnixMilli(), Snapshot: snapshot, Klines: klines, - Derivatives: 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, - }, - Technical: u.indicator.Compute(klines[derivativePeriod], globalLS), + Derivatives: derivBundle, + Technical: u.indicator.Compute(klines, derivativePeriod, globalLS), DataQuality: entity.DataQuality{ Source: "binance", Warnings: warnings, diff --git a/internal/usecase/ports.go b/internal/usecase/ports.go index 576b87b..2425631 100644 --- a/internal/usecase/ports.go +++ b/internal/usecase/ports.go @@ -58,7 +58,34 @@ type TakerVolumeRepository interface { // 过 kline 与 long-short ratio,复用结果即可,二次注入仓库引用会 // 重复 IO。 // +// klinesByInterval 是按周期分组的 K 线 map(每周期一份升序切片); +// primaryInterval 指定哪个周期参与 support/resistance/range/longShortLine +// 的计算(其它周期只参与 Intervals 多周期指标如 Bollinger / Vegas)。 // 实现可见 internal/usecase/indicator.go。 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) }