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:
dela
2026-05-24 23:19:57 +08:00
parent 622ff1d317
commit fa769331c2
16 changed files with 912 additions and 66 deletions

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