// 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 }