Files
cryptoHermes/internal/usecase/market_context.go
dela 21b3078094 feat(indicator): per-interval S/R + Range + LSLine + Donchian 箱体(ADR-0003)
按 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%)。
2026-05-25 10:44:33 +08:00

255 lines
6.3 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
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
}