Files
cryptoHermes/internal/usecase/derivatives_signal.go
dela fa769331c2 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 更新)
2026-05-24 23:19:57 +08:00

216 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 rate8h 结算)超过 ±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_buildingprice 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
}