- 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 更新)
216 lines
7.0 KiB
Go
216 lines
7.0 KiB
Go
// 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
|
||
}
|