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
}

View File

@@ -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 根降到 800OI 在跌,不看价格
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)
}

View File

@@ -4,6 +4,8 @@
// - Support / Resistancepivot 局部极值 + 0.3% 价格聚类
// - RangeHigh / RangeLow近 N 根 P95(High) / P5(Low) 线性插值
// - LongShortLine近 N 根 LSR 穿越 1.0 的价位中位数
// - Bollingerper intervalSMA20 + 2σ
// - Vegasper intervalEMA12/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。
// Trendfast > mid > slow → bullfast < 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,
}
}

View File

@@ -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 生成等差递增 closesbase, 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()
// 给 1h25 根) 与 4h200 根) 喂上升序列
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)
}

View File

@@ -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,

View File

@@ -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_buildingfunding 不参与 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)
}