按 ADR-0003 把 Support / Resistance / Range / LSLine 从顶层下沉进 IntervalTechnicals,每个周期独立计算;顶层 5 字段保留为 Intervals[primary] 镜像(单一来源,不双写)以维持向后兼容的 JSON shape。 新增 BoxBreak(Donchian 风格,lookback=20 根不含当前根,K 线 < 21 → nil), 落到 IntervalTechnicals.Box。 IndicatorComputer.Compute 签名升级到 longShortByInterval map: market_context 改为并发拉取 15m/1h/4h/1d 的全局 LSR,1w 不在 Binance LSR 支持列表 → 加 warning lsr_unsupported_interval:1w。 测试:boxBreak 6 个 case(inside/break_up/break_down/insufficient/2 个 parse 失败)+ per-interval Support/Resistance/Range 跨周期断言 + 镜像不变量 + 缺 LSR key → LongShortLine nil。indicator.go 平均覆盖 97.26%(boxBreak 100%)。
255 lines
6.3 KiB
Go
255 lines
6.3 KiB
Go
package usecase
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/sync/errgroup"
|
||
|
||
"cryptoHermes/internal/entity"
|
||
)
|
||
|
||
var supportedSymbols = map[string]bool{
|
||
"BTCUSDT": true,
|
||
"ETHUSDT": true,
|
||
"SOLUSDT": true,
|
||
"BNBUSDT": true,
|
||
"DOGEUSDT": true,
|
||
}
|
||
|
||
var supportedIntervals = []string{"15m", "1h", "4h", "1d", "1w"}
|
||
|
||
// lsrSupportedIntervals 是 Binance Futures Global LSR 接口支持的 period 集合。
|
||
// 1w 不在列表内 → 该周期 IntervalTechnicals.LongShortLine 为 nil,外加 warning。
|
||
var lsrSupportedIntervals = []string{"15m", "1h", "4h", "1d"}
|
||
|
||
const (
|
||
klineWindowSize = 300
|
||
klineMinForOK = 200
|
||
derivativePeriod = "1h"
|
||
fundingHistoryLen = 100
|
||
oiHistoryLen = 200
|
||
longShortLen = 200
|
||
takerHistoryLen = 200
|
||
)
|
||
|
||
type MarketContextUsecase struct {
|
||
marketData MarketDataProvider
|
||
derivatives DerivativesProvider
|
||
|
||
klineRepo KlineRepository
|
||
fundingRepo FundingRepository
|
||
oiRepo OpenInterestRepository
|
||
lsRepo LongShortRatioRepository
|
||
|
||
indicator IndicatorComputer
|
||
derivSignal DerivativesSignalComputer
|
||
|
||
log *slog.Logger
|
||
}
|
||
|
||
func NewMarketContextUsecase(
|
||
marketData MarketDataProvider,
|
||
derivatives DerivativesProvider,
|
||
klineRepo KlineRepository,
|
||
fundingRepo FundingRepository,
|
||
oiRepo OpenInterestRepository,
|
||
lsRepo LongShortRatioRepository,
|
||
indicator IndicatorComputer,
|
||
derivSignal DerivativesSignalComputer,
|
||
log *slog.Logger,
|
||
) *MarketContextUsecase {
|
||
return &MarketContextUsecase{
|
||
marketData: marketData,
|
||
derivatives: derivatives,
|
||
klineRepo: klineRepo,
|
||
fundingRepo: fundingRepo,
|
||
oiRepo: oiRepo,
|
||
lsRepo: lsRepo,
|
||
indicator: indicator,
|
||
derivSignal: derivSignal,
|
||
log: log,
|
||
}
|
||
}
|
||
|
||
func (u *MarketContextUsecase) Build(ctx context.Context, symbol string) (*entity.MarketContext, error) {
|
||
if !supportedSymbols[symbol] {
|
||
return nil, fmt.Errorf("unsupported symbol: %s", symbol)
|
||
}
|
||
|
||
var (
|
||
snapshot *entity.Ticker24h
|
||
currentFund *entity.FundingRate
|
||
currentOI *entity.OpenInterest
|
||
warnMu sync.Mutex
|
||
warnings []string
|
||
)
|
||
addWarn := func(w string) {
|
||
warnMu.Lock()
|
||
warnings = append(warnings, w)
|
||
warnMu.Unlock()
|
||
}
|
||
|
||
g, gctx := errgroup.WithContext(ctx)
|
||
|
||
g.Go(func() error {
|
||
s, err := u.marketData.GetTicker24h(gctx, symbol)
|
||
if err != nil {
|
||
addWarn("snapshot fetch failed: " + err.Error())
|
||
return nil
|
||
}
|
||
snapshot = s
|
||
return nil
|
||
})
|
||
g.Go(func() error {
|
||
f, err := u.derivatives.GetCurrentFunding(gctx, symbol)
|
||
if err != nil {
|
||
addWarn("current funding fetch failed: " + err.Error())
|
||
return nil
|
||
}
|
||
currentFund = f
|
||
return nil
|
||
})
|
||
g.Go(func() error {
|
||
oi, err := u.derivatives.GetCurrentOpenInterest(gctx, symbol)
|
||
if err != nil {
|
||
addWarn("current OI fetch failed: " + err.Error())
|
||
return nil
|
||
}
|
||
currentOI = oi
|
||
return nil
|
||
})
|
||
|
||
_ = g.Wait()
|
||
|
||
klines := make(map[string][]entity.Kline, len(supportedIntervals))
|
||
for _, iv := range supportedIntervals {
|
||
rows, err := u.klineRepo.FindRecent(ctx, symbol, iv, klineWindowSize)
|
||
if err != nil {
|
||
addWarn(fmt.Sprintf("klines DB query failed for %s: %v", iv, err))
|
||
rows = nil
|
||
}
|
||
if len(rows) < klineMinForOK {
|
||
addWarn(fmt.Sprintf("klines %s only %d in DB, falling back to Binance", iv, len(rows)))
|
||
fresh, ferr := u.marketData.GetKlines(ctx, symbol, iv, klineWindowSize)
|
||
if ferr != nil {
|
||
addWarn(fmt.Sprintf("klines fallback %s failed: %v", iv, ferr))
|
||
} else {
|
||
closed := make([]entity.Kline, 0, len(fresh))
|
||
for _, k := range fresh {
|
||
if k.IsClosed {
|
||
closed = append(closed, k)
|
||
}
|
||
}
|
||
go func(items []entity.Kline) {
|
||
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
if err := u.klineRepo.UpsertMany(bgCtx, items); err != nil {
|
||
u.log.Error("background_kline_upsert_failed", "err", err)
|
||
}
|
||
}(closed)
|
||
rows = closed
|
||
}
|
||
}
|
||
klines[iv] = rows
|
||
}
|
||
|
||
fundingHist, err := u.fundingRepo.FindRecent(ctx, symbol, fundingHistoryLen)
|
||
if err != nil {
|
||
addWarn("funding history query failed: " + err.Error())
|
||
}
|
||
|
||
oiHist, err := u.oiRepo.FindRecent(ctx, symbol, derivativePeriod, oiHistoryLen)
|
||
if err != nil {
|
||
addWarn("OI history query failed: " + err.Error())
|
||
}
|
||
|
||
globalLSByInterval := make(map[string][]entity.LongShortRatio, len(lsrSupportedIntervals))
|
||
var lsMu sync.Mutex
|
||
lsG, lsCtx := errgroup.WithContext(ctx)
|
||
for _, iv := range lsrSupportedIntervals {
|
||
iv := iv
|
||
lsG.Go(func() error {
|
||
rows, err := u.lsRepo.FindRecent(lsCtx, symbol, iv, entity.RatioTypeGlobalAccount, longShortLen)
|
||
if err != nil {
|
||
addWarn(fmt.Sprintf("global long/short query failed for %s: %v", iv, err))
|
||
return nil
|
||
}
|
||
lsMu.Lock()
|
||
globalLSByInterval[iv] = rows
|
||
lsMu.Unlock()
|
||
return nil
|
||
})
|
||
}
|
||
_ = lsG.Wait()
|
||
for _, iv := range supportedIntervals {
|
||
if !lsrSupports(iv) {
|
||
addWarn("lsr_unsupported_interval:" + iv)
|
||
}
|
||
}
|
||
globalLS := globalLSByInterval[derivativePeriod]
|
||
|
||
topLS, err := u.lsRepo.FindRecent(ctx, symbol, derivativePeriod, entity.RatioTypeTopTraderPosition, longShortLen)
|
||
if err != nil {
|
||
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: derivBundle,
|
||
Technical: u.indicator.Compute(klines, derivativePeriod, globalLSByInterval),
|
||
DataQuality: entity.DataQuality{
|
||
Source: "binance",
|
||
Warnings: warnings,
|
||
},
|
||
}
|
||
|
||
if out.DataQuality.Warnings == nil {
|
||
out.DataQuality.Warnings = []string{}
|
||
}
|
||
|
||
if snapshot == nil {
|
||
return out, errors.New("market snapshot unavailable")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func lsrSupports(interval string) bool {
|
||
for _, iv := range lsrSupportedIntervals {
|
||
if iv == interval {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|