From e0b4d026d3834124f45eb12b2af95f01b883b22f Mon Sep 17 00:00:00 2001 From: 0xYYBB | ZYY | Bobo <128128010+the-dev-z@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:41:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(market):=20add=20data=20staleness=20detect?= =?UTF-8?q?ion=20(Part=202/3)=20(#800)=20*=20feat(market):=20add=20data=20?= =?UTF-8?q?staleness=20detection=20##=20=E5=95=8F=E9=A1=8C=E8=83=8C?= =?UTF-8?q?=E6=99=AF=20=E8=A7=A3=E6=B1=BA=20PR=20#703=20Part=202:=20?= =?UTF-8?q?=E6=95=B8=E6=93=9A=E9=99=B3=E8=88=8A=E6=80=A7=E6=AA=A2=E6=B8=AC?= =?UTF-8?q?=20-=20=E4=BF=AE=E5=BE=A9=20DOGEUSDT=20=E5=BC=8F=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=EF=BC=9A=E9=80=A3=E7=BA=8C=E5=83=B9=E6=A0=BC=E4=B8=8D?= =?UTF-8?q?=E8=AE=8A=E8=A1=A8=E7=A4=BA=E6=95=B8=E6=93=9A=E6=BA=90=E7=95=B0?= =?UTF-8?q?=E5=B8=B8=20-=20=E9=98=B2=E6=AD=A2=E7=B3=BB=E7=B5=B1=E8=99=95?= =?UTF-8?q?=E7=90=86=E5=83=B5=E5=8C=96/=E9=81=8E=E6=9C=9F=E7=9A=84?= =?UTF-8?q?=E5=B8=82=E5=A0=B4=E6=95=B8=E6=93=9A=20##=20=E6=8A=80=E8=A1=93?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20###=20=E6=95=B8=E6=93=9A=E9=99=B3=E8=88=8A?= =?UTF-8?q?=E6=80=A7=E6=AA=A2=E6=B8=AC=20(market/data.go)=20-=20**?= =?UTF-8?q?=E5=87=BD=E6=95=B8**:=20`isStaleData(klines=20[]Kline,=20symbol?= =?UTF-8?q?=20string)=20bool`=20-=20**=E6=AA=A2=E6=B8=AC=E9=82=8F=E8=BC=AF?= =?UTF-8?q?**:=20=20=20-=20=E9=80=A3=E7=BA=8C=205=20=E5=80=8B=203=20?= =?UTF-8?q?=E5=88=86=E9=90=98=E9=80=B1=E6=9C=9F=E5=83=B9=E6=A0=BC=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=E4=B8=8D=E8=AE=8A=EF=BC=8815=20=E5=88=86=E9=90=98?= =?UTF-8?q?=E7=84=A1=E6=B3=A2=E5=8B=95=EF=BC=89=20=20=20-=20=E5=83=B9?= =?UTF-8?q?=E6=A0=BC=E6=B3=A2=E5=8B=95=E5=AE=B9=E5=BF=8D=E5=BA=A6=EF=BC=9A?= =?UTF-8?q?0.01%=EF=BC=88=E9=81=BF=E5=85=8D=E8=AA=A4=E5=A0=B1=EF=BC=89=20?= =?UTF-8?q?=20=20-=20=E6=88=90=E4=BA=A4=E9=87=8F=E6=AA=A2=E6=9F=A5?= =?UTF-8?q?=EF=BC=9A=E5=83=B9=E6=A0=BC=E5=87=8D=E7=B5=90=20+=20=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E9=87=8F=E7=82=BA=200=20=E2=86=92=20=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E9=99=B3=E8=88=8A=20-=20**=E8=99=95=E7=90=86=E7=AD=96?= =?UTF-8?q?=E7=95=A5**:=20=20=20-=20=E6=95=B8=E6=93=9A=E9=99=B3=E8=88=8A?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=EF=BC=9A=E8=B7=B3=E9=81=8E=E8=A9=B2=E5=B9=A3?= =?UTF-8?q?=E7=A8=AE=EF=BC=8C=E8=BF=94=E5=9B=9E=E9=8C=AF=E8=AA=A4=20=20=20?= =?UTF-8?q?-=20=E6=A5=B5=E4=BD=8E=E6=B3=A2=E5=8B=95=E5=B8=82=E5=A0=B4?= =?UTF-8?q?=EF=BC=9A=E8=A8=98=E9=8C=84=E8=AD=A6=E5=91=8A=E4=BD=86=E5=85=81?= =?UTF-8?q?=E8=A8=B1=E9=80=9A=E9=81=8E=EF=BC=88=E5=83=B9=E6=A0=BC=E7=A9=A9?= =?UTF-8?q?=E5=AE=9A=E4=BD=86=E6=9C=89=E6=88=90=E4=BA=A4=E9=87=8F=EF=BC=89?= =?UTF-8?q?=20###=20=E8=AA=BF=E7=94=A8=E6=99=82=E6=A9=9F=20-=20=E5=9C=A8?= =?UTF-8?q?=20`Get()`=20=E5=87=BD=E6=95=B8=E4=B8=AD=EF=BC=8C=E7=8D=B2?= =?UTF-8?q?=E5=8F=96=203m=20K=E7=B7=9A=E5=BE=8C=E7=AB=8B=E5=8D=B3=E6=AA=A2?= =?UTF-8?q?=E6=B8=AC=20-=20=E6=97=A9=E6=9C=9F=E8=BF=94=E5=9B=9E=EF=BC=9A?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=BE=8C=E7=BA=8C=E7=84=A1=E6=84=8F=E7=BE=A9?= =?UTF-8?q?=E7=9A=84=E8=A8=88=E7=AE=97=E5=92=8C=20API=20=E8=AA=BF=E7=94=A8?= =?UTF-8?q?=20##=20=E5=AF=A6=E7=8F=BE=E7=B4=B0=E7=AF=80=20-=20**=E6=AA=A2?= =?UTF-8?q?=E6=B8=AC=E9=96=BE=E5=80=BC**:=205=20=E5=80=8B=E9=80=A3?= =?UTF-8?q?=E7=BA=8C=E9=80=B1=E6=9C=9F=20-=20**=E5=AE=B9=E5=BF=8D=E5=BA=A6?= =?UTF-8?q?**:=200.01%=20=E5=83=B9=E6=A0=BC=E6=B3=A2=E5=8B=95=20-=20**?= =?UTF-8?q?=E6=97=A5=E8=AA=8C**:=20=E8=8B=B1=E6=96=87=E5=9C=8B=E9=9A=9B?= =?UTF-8?q?=E5=8C=96=E7=89=88=E6=9C=AC=20-=20**=E4=B8=A6=E7=99=BC=E5=AE=89?= =?UTF-8?q?=E5=85=A8**:=20=E5=87=BD=E6=95=B8=E7=84=A1=E7=8B=80=E6=85=8B?= =?UTF-8?q?=EF=BC=8C=E5=AE=89=E5=85=A8=20##=20=E5=BD=B1=E9=9F=BF=E7=AF=84?= =?UTF-8?q?=E5=9C=8D=20-=20=E2=9C=85=20=E4=BF=AE=E6=94=B9=20market/data.go?= =?UTF-8?q?:=20=E6=96=B0=E5=A2=9E=20isStaleData()=20+=20=E8=AA=BF=E7=94=A8?= =?UTF-8?q?=E9=82=8F=E8=BC=AF=20-=20=E2=9C=85=20=E6=96=B0=E5=A2=9E=20log?= =?UTF-8?q?=20=E5=8C=85=E5=B0=8E=E5=85=A5=20-=20=E2=9C=85=2050=20=E8=A1=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=A3=E7=A2=BC=20##=20=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E5=BB=BA=E8=AD=B0=201.=20=E6=A8=A1=E6=93=AC=20DOGEUSDT=20?= =?UTF-8?q?=E5=A0=B4=E6=99=AF=EF=BC=9A=E9=80=A3=E7=BA=8C=E5=83=B9=E6=A0=BC?= =?UTF-8?q?=E4=B8=8D=E8=AE=8A=20+=20=E6=88=90=E4=BA=A4=E9=87=8F=E7=82=BA?= =?UTF-8?q?=200=202.=20=E9=A9=97=E8=AD=89=E6=97=A5=E8=AA=8C=E8=BC=B8?= =?UTF-8?q?=E5=87=BA=EF=BC=9A`stale=20data=20confirmed:=20price=20freeze?= =?UTF-8?q?=20+=20zero=20volume`=203.=20=E6=AD=A3=E5=B8=B8=E5=B8=82?= =?UTF-8?q?=E5=A0=B4=EF=BC=9A=E6=A5=B5=E4=BD=8E=E6=B3=A2=E5=8B=95=E4=BD=86?= =?UTF-8?q?=E6=9C=89=E6=88=90=E4=BA=A4=E9=87=8F=EF=BC=8C=E6=87=89=E5=85=81?= =?UTF-8?q?=E8=A8=B1=E9=80=9A=E9=81=8E=E4=B8=A6=E8=A8=98=E9=8C=84=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=20##=20=E7=9B=B8=E9=97=9C=20Issue/PR=20-=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=E8=87=AA=20**PR=20#703**=20(Part=202/3)=20-=20?= =?UTF-8?q?=E5=9F=BA=E6=96=BC=E6=9C=80=E6=96=B0=20upstream/dev=20(3112250)?= =?UTF-8?q?=20-=20=E4=BE=9D=E8=B3=B4:=20=E7=84=A1=20-=20=E5=89=8D=E7=BD=AE?= =?UTF-8?q?:=20Part=201=20(OI=20=E6=99=82=E9=96=93=E5=BA=8F=E5=88=97)=20-?= =?UTF-8?q?=20=E5=B7=B2=E6=8F=90=E4=BA=A4=20PR=20#798=20-=20=E5=BE=8C?= =?UTF-8?q?=E7=BA=8C:=20Part=203=20(=E6=89=8B=E7=BA=8C=E8=B2=BB=E7=8E=87?= =?UTF-8?q?=E5=82=B3=E9=81=9E)=20Co-Authored-By:=20tinkle-community=20=20*=20test(market):=20add=20comprehensive=20?= =?UTF-8?q?unit=20tests=20for=20isStaleData=20function=20-=20Test=20normal?= =?UTF-8?q?=20fluctuating=20data=20(expects=20non-stale)=20-=20Test=20pric?= =?UTF-8?q?e=20freeze=20with=20zero=20volume=20(expects=20stale)=20-=20Tes?= =?UTF-8?q?t=20price=20freeze=20with=20volume=20(low=20volatility=20market?= =?UTF-8?q?)=20-=20Test=20insufficient=20data=20edge=20case=20(<5=20klines?= =?UTF-8?q?)=20-=20Test=20boundary=20conditions=20(exactly=205=20klines)?= =?UTF-8?q?=20-=20Test=20tolerance=20threshold=20(0.01%=20price=20change)?= =?UTF-8?q?=20-=20Test=20mixed=20scenario=20(normal=20=E2=86=92=20freeze?= =?UTF-8?q?=20transition)=20-=20Test=20empty=20klines=20edge=20case=20All?= =?UTF-8?q?=208=20test=20cases=20passed.=20Co-Authored-By:=20tinkle-commun?= =?UTF-8?q?ity=20=20---------=20Co-authored-by:=20Zh?= =?UTF-8?q?ouYongyou=20<128128010+zhouyongyou@users.noreply.github.com>=20?= =?UTF-8?q?Co-authored-by:=20tinkle-community=20=20C?= =?UTF-8?q?o-authored-by:=20Shui=20<88711385+hzb1115@users.noreply.github.?= =?UTF-8?q?com>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- market/data.go | 51 +++++++++++++ market/data_test.go | 177 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 216 insertions(+), 12 deletions(-) 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") + } +}