diff --git a/market/data.go b/market/data.go index f3f1c586..3ea1a248 100644 --- a/market/data.go +++ b/market/data.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "log" "math" "strconv" "strings" @@ -35,6 +36,12 @@ func Get(symbol string) (*Data, error) { return nil, fmt.Errorf("获取3分钟K线失败: %v", err) } + // Data staleness detection: Prevent DOGEUSDT-style price freeze issues + if isStaleData(klines3m, symbol) { + log.Printf("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol) + return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol) + } + // 获取4小时K线数据 (最近10个) klines4h, err = WSMonitorCli.GetCurrentKlines(symbol, "4h") // 多获取用于计算指标 if err != nil { @@ -541,3 +548,47 @@ func parseFloat(v interface{}) (float64, error) { return 0, fmt.Errorf("unsupported type: %T", v) } } + +// isStaleData detects stale data (consecutive price freeze) +// Fix DOGEUSDT-style issue: consecutive N periods with completely unchanged prices indicate data source anomaly +func isStaleData(klines []Kline, symbol string) bool { + if len(klines) < 5 { + return false // Insufficient data to determine + } + + // Detection threshold: 5 consecutive 3-minute periods with unchanged price (15 minutes without fluctuation) + const stalePriceThreshold = 5 + const priceTolerancePct = 0.0001 // 0.01% fluctuation tolerance (avoid false positives) + + // Take the last stalePriceThreshold K-lines + recentKlines := klines[len(klines)-stalePriceThreshold:] + firstPrice := recentKlines[0].Close + + // Check if all prices are within tolerance + for i := 1; i < len(recentKlines); i++ { + priceDiff := math.Abs(recentKlines[i].Close-firstPrice) / firstPrice + if priceDiff > priceTolerancePct { + return false // Price fluctuation exists, data is normal + } + } + + // Additional check: MACD and volume + // If price is unchanged but MACD/volume shows normal fluctuation, it might be a real market situation (extremely low volatility) + // Check if volume is also 0 (data completely frozen) + allVolumeZero := true + for _, k := range recentKlines { + if k.Volume > 0 { + allVolumeZero = false + break + } + } + + if allVolumeZero { + log.Printf("⚠️ %s stale data confirmed: price freeze + zero volume", symbol) + return true + } + + // Price frozen but has volume: might be extremely low volatility market, allow but log warning + log.Printf("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold) + return false +} diff --git a/market/data_test.go b/market/data_test.go index b0b34f0f..984e727d 100644 --- a/market/data_test.go +++ b/market/data_test.go @@ -131,19 +131,19 @@ func TestCalculateIntradaySeries_VolumeValues(t *testing.T) { // TestCalculateIntradaySeries_ATR14 测试 ATR14 计算 func TestCalculateIntradaySeries_ATR14(t *testing.T) { tests := []struct { - name string - klineCount int - expectZero bool + name string + klineCount int + expectZero bool expectNonZero bool }{ { - name: "足够数据 - 20个K线", - klineCount: 20, + name: "足够数据 - 20个K线", + klineCount: 20, expectNonZero: true, }, { - name: "刚好15个K线(ATR14需要至少15个)", - klineCount: 15, + name: "刚好15个K线(ATR14需要至少15个)", + klineCount: 15, expectNonZero: true, }, { @@ -253,11 +253,11 @@ func TestCalculateATR(t *testing.T) { func TestCalculateATR_TrueRange(t *testing.T) { // 创建一个简单的测试用例,手动计算期望的 ATR klines := []Kline{ - {High: 50.0, Low: 48.0, Close: 49.0}, // TR = 2.0 - {High: 51.0, Low: 49.0, Close: 50.0}, // TR = max(2.0, 2.0, 1.0) = 2.0 - {High: 52.0, Low: 50.0, Close: 51.0}, // TR = max(2.0, 2.0, 1.0) = 2.0 - {High: 53.0, Low: 51.0, Close: 52.0}, // TR = 2.0 - {High: 54.0, Low: 52.0, Close: 53.0}, // TR = 2.0 + {High: 50.0, Low: 48.0, Close: 49.0}, // TR = 2.0 + {High: 51.0, Low: 49.0, Close: 50.0}, // TR = max(2.0, 2.0, 1.0) = 2.0 + {High: 52.0, Low: 50.0, Close: 51.0}, // TR = max(2.0, 2.0, 1.0) = 2.0 + {High: 53.0, Low: 51.0, Close: 52.0}, // TR = 2.0 + {High: 54.0, Low: 52.0, Close: 53.0}, // TR = 2.0 } atr := calculateATR(klines, 3) @@ -347,3 +347,156 @@ func TestCalculateIntradaySeries_VolumePrecision(t *testing.T) { } } } + +// TestIsStaleData_NormalData tests that normal fluctuating data returns false +func TestIsStaleData_NormalData(t *testing.T) { + klines := []Kline{ + {Close: 100.0, Volume: 1000}, + {Close: 100.5, Volume: 1200}, + {Close: 99.8, Volume: 900}, + {Close: 100.2, Volume: 1100}, + {Close: 100.1, Volume: 950}, + } + + result := isStaleData(klines, "BTCUSDT") + + if result { + t.Error("Expected false for normal fluctuating data, got true") + } +} + +// TestIsStaleData_PriceFreezeWithZeroVolume tests that frozen price + zero volume returns true +func TestIsStaleData_PriceFreezeWithZeroVolume(t *testing.T) { + klines := []Kline{ + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + } + + result := isStaleData(klines, "DOGEUSDT") + + if !result { + t.Error("Expected true for frozen price + zero volume, got false") + } +} + +// TestIsStaleData_PriceFreezeWithVolume tests that frozen price but normal volume returns false +func TestIsStaleData_PriceFreezeWithVolume(t *testing.T) { + klines := []Kline{ + {Close: 100.0, Volume: 1000}, + {Close: 100.0, Volume: 1200}, + {Close: 100.0, Volume: 900}, + {Close: 100.0, Volume: 1100}, + {Close: 100.0, Volume: 950}, + } + + result := isStaleData(klines, "STABLECOIN") + + if result { + t.Error("Expected false for frozen price but normal volume (low volatility market), got true") + } +} + +// TestIsStaleData_InsufficientData tests that insufficient data (<5 klines) returns false +func TestIsStaleData_InsufficientData(t *testing.T) { + klines := []Kline{ + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + } + + result := isStaleData(klines, "BTCUSDT") + + if result { + t.Error("Expected false for insufficient data (<5 klines), got true") + } +} + +// TestIsStaleData_ExactlyFiveKlines tests edge case with exactly 5 klines +func TestIsStaleData_ExactlyFiveKlines(t *testing.T) { + // Stale case: exactly 5 frozen klines with zero volume + staleKlines := []Kline{ + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + {Close: 100.0, Volume: 0}, + } + + result := isStaleData(staleKlines, "TESTUSDT") + if !result { + t.Error("Expected true for exactly 5 frozen klines with zero volume, got false") + } + + // Normal case: exactly 5 klines with fluctuation + normalKlines := []Kline{ + {Close: 100.0, Volume: 1000}, + {Close: 100.1, Volume: 1100}, + {Close: 99.9, Volume: 900}, + {Close: 100.0, Volume: 1000}, + {Close: 100.05, Volume: 950}, + } + + result = isStaleData(normalKlines, "TESTUSDT") + if result { + t.Error("Expected false for exactly 5 normal klines, got true") + } +} + +// TestIsStaleData_WithinTolerance tests price changes within tolerance (0.01%) +func TestIsStaleData_WithinTolerance(t *testing.T) { + // Price changes within 0.01% tolerance should be treated as frozen + basePrice := 10000.0 + tolerance := 0.0001 // 0.01% + smallChange := basePrice * tolerance * 0.5 // Half of tolerance + + klines := []Kline{ + {Close: basePrice, Volume: 1000}, + {Close: basePrice + smallChange, Volume: 1000}, + {Close: basePrice - smallChange, Volume: 1000}, + {Close: basePrice, Volume: 1000}, + {Close: basePrice + smallChange, Volume: 1000}, + } + + result := isStaleData(klines, "BTCUSDT") + + // Should return false because there's normal volume despite tiny price changes + if result { + t.Error("Expected false for price within tolerance but with volume, got true") + } +} + +// TestIsStaleData_MixedScenario tests realistic scenario with some history before freeze +func TestIsStaleData_MixedScenario(t *testing.T) { + // Simulate: normal trading → suddenly freezes + klines := []Kline{ + {Close: 100.0, Volume: 1000}, // Normal + {Close: 100.5, Volume: 1200}, // Normal + {Close: 100.2, Volume: 1100}, // Normal + {Close: 50.0, Volume: 0}, // Freeze starts + {Close: 50.0, Volume: 0}, // Frozen + {Close: 50.0, Volume: 0}, // Frozen + {Close: 50.0, Volume: 0}, // Frozen + {Close: 50.0, Volume: 0}, // Frozen (last 5 are all frozen) + } + + result := isStaleData(klines, "DOGEUSDT") + + // Should detect stale data based on last 5 klines + if !result { + t.Error("Expected true for frozen last 5 klines with zero volume, got false") + } +} + +// TestIsStaleData_EmptyKlines tests edge case with empty slice +func TestIsStaleData_EmptyKlines(t *testing.T) { + klines := []Kline{} + + result := isStaleData(klines, "BTCUSDT") + + if result { + t.Error("Expected false for empty klines, got true") + } +}