Files
nofx/market/data_test.go
0xYYBB | ZYY | Bobo c5abcf1f2c feat(market): add data staleness detection (Part 2/3) (#800)
* feat(market): add data staleness detection

## 問題背景
解決 PR #703 Part 2: 數據陳舊性檢測
- 修復 DOGEUSDT 式問題:連續價格不變表示數據源異常
- 防止系統處理僵化/過期的市場數據

## 技術方案

### 數據陳舊性檢測 (market/data.go)
- **函數**: `isStaleData(klines []Kline, symbol string) bool`
- **檢測邏輯**:
  - 連續 5 個 3 分鐘週期價格完全不變(15 分鐘無波動)
  - 價格波動容忍度:0.01%(避免誤報)
  - 成交量檢查:價格凍結 + 成交量為 0 → 確認陳舊

- **處理策略**:
  - 數據陳舊確認:跳過該幣種,返回錯誤
  - 極低波動市場:記錄警告但允許通過(價格穩定但有成交量)

### 調用時機
- 在 `Get()` 函數中,獲取 3m K線後立即檢測
- 早期返回:避免後續無意義的計算和 API 調用

## 實現細節
- **檢測閾值**: 5 個連續週期
- **容忍度**: 0.01% 價格波動
- **日誌**: 英文國際化版本
- **並發安全**: 函數無狀態,安全

## 影響範圍
-  修改 market/data.go: 新增 isStaleData() + 調用邏輯
-  新增 log 包導入
-  50 行新增代碼

## 測試建議
1. 模擬 DOGEUSDT 場景:連續價格不變 + 成交量為 0
2. 驗證日誌輸出:`stale data confirmed: price freeze + zero volume`
3. 正常市場:極低波動但有成交量,應允許通過並記錄警告

## 相關 Issue/PR
- 拆分自 **PR #703** (Part 2/3)
- 基於最新 upstream/dev (3112250)
- 依賴: 無
- 前置: Part 1 (OI 時間序列) - 已提交 PR #798
- 後續: Part 3 (手續費率傳遞)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

* test(market): add comprehensive unit tests for isStaleData function

- Test normal fluctuating data (expects non-stale)
- Test price freeze with zero volume (expects stale)
- Test price freeze with volume (low volatility market)
- Test insufficient data edge case (<5 klines)
- Test boundary conditions (exactly 5 klines)
- Test tolerance threshold (0.01% price change)
- Test mixed scenario (normal → freeze transition)
- Test empty klines edge case

All 8 test cases passed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

---------

Co-authored-by: ZhouYongyou <128128010+zhouyongyou@users.noreply.github.com>
Co-authored-by: tinkle-community <tinklefund@gmail.com>
Co-authored-by: Shui <88711385+hzb1115@users.noreply.github.com>
2025-11-11 21:41:26 -05:00

503 lines
14 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 market
import (
"math"
"testing"
)
// generateTestKlines 生成测试用的 K线数据
func generateTestKlines(count int) []Kline {
klines := make([]Kline, count)
for i := 0; i < count; i++ {
// 生成模拟的价格数据,有一定的波动
basePrice := 100.0
variance := float64(i%10) * 0.5
open := basePrice + variance
high := open + 1.0
low := open - 0.5
close := open + 0.3
volume := 1000.0 + float64(i*100)
klines[i] = Kline{
OpenTime: int64(i * 180000), // 3分钟间隔
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume,
CloseTime: int64((i+1)*180000 - 1),
}
}
return klines
}
// TestCalculateIntradaySeries_VolumeCollection 测试 Volume 数据收集
func TestCalculateIntradaySeries_VolumeCollection(t *testing.T) {
tests := []struct {
name string
klineCount int
expectedVolLen int
}{
{
name: "正常情况 - 20个K线",
klineCount: 20,
expectedVolLen: 10, // 应该收集最近10个
},
{
name: "刚好10个K线",
klineCount: 10,
expectedVolLen: 10,
},
{
name: "少于10个K线",
klineCount: 5,
expectedVolLen: 5, // 应该返回所有5个
},
{
name: "超过10个K线",
klineCount: 30,
expectedVolLen: 10, // 应该只返回最近10个
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
klines := generateTestKlines(tt.klineCount)
data := calculateIntradaySeries(klines)
if data == nil {
t.Fatal("calculateIntradaySeries returned nil")
}
if len(data.Volume) != tt.expectedVolLen {
t.Errorf("Volume length = %d, want %d", len(data.Volume), tt.expectedVolLen)
}
// 验证 Volume 数据正确性
if len(data.Volume) > 0 {
// 计算期望的起始索引
start := tt.klineCount - 10
if start < 0 {
start = 0
}
// 验证第一个 Volume 值
expectedFirstVolume := klines[start].Volume
if data.Volume[0] != expectedFirstVolume {
t.Errorf("First volume = %.2f, want %.2f", data.Volume[0], expectedFirstVolume)
}
// 验证最后一个 Volume 值
expectedLastVolume := klines[tt.klineCount-1].Volume
lastVolume := data.Volume[len(data.Volume)-1]
if lastVolume != expectedLastVolume {
t.Errorf("Last volume = %.2f, want %.2f", lastVolume, expectedLastVolume)
}
}
})
}
}
// TestCalculateIntradaySeries_VolumeValues 测试 Volume 值的正确性
func TestCalculateIntradaySeries_VolumeValues(t *testing.T) {
klines := []Kline{
{Close: 100.0, Volume: 1000.0, High: 101.0, Low: 99.0, Open: 100.0},
{Close: 101.0, Volume: 1100.0, High: 102.0, Low: 100.0, Open: 101.0},
{Close: 102.0, Volume: 1200.0, High: 103.0, Low: 101.0, Open: 102.0},
{Close: 103.0, Volume: 1300.0, High: 104.0, Low: 102.0, Open: 103.0},
{Close: 104.0, Volume: 1400.0, High: 105.0, Low: 103.0, Open: 104.0},
{Close: 105.0, Volume: 1500.0, High: 106.0, Low: 104.0, Open: 105.0},
{Close: 106.0, Volume: 1600.0, High: 107.0, Low: 105.0, Open: 106.0},
{Close: 107.0, Volume: 1700.0, High: 108.0, Low: 106.0, Open: 107.0},
{Close: 108.0, Volume: 1800.0, High: 109.0, Low: 107.0, Open: 108.0},
{Close: 109.0, Volume: 1900.0, High: 110.0, Low: 108.0, Open: 109.0},
}
data := calculateIntradaySeries(klines)
expectedVolumes := []float64{1000.0, 1100.0, 1200.0, 1300.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0}
if len(data.Volume) != len(expectedVolumes) {
t.Fatalf("Volume length = %d, want %d", len(data.Volume), len(expectedVolumes))
}
for i, expected := range expectedVolumes {
if data.Volume[i] != expected {
t.Errorf("Volume[%d] = %.2f, want %.2f", i, data.Volume[i], expected)
}
}
}
// TestCalculateIntradaySeries_ATR14 测试 ATR14 计算
func TestCalculateIntradaySeries_ATR14(t *testing.T) {
tests := []struct {
name string
klineCount int
expectZero bool
expectNonZero bool
}{
{
name: "足够数据 - 20个K线",
klineCount: 20,
expectNonZero: true,
},
{
name: "刚好15个K线ATR14需要至少15个",
klineCount: 15,
expectNonZero: true,
},
{
name: "数据不足 - 14个K线",
klineCount: 14,
expectZero: true,
},
{
name: "数据不足 - 10个K线",
klineCount: 10,
expectZero: true,
},
{
name: "数据不足 - 5个K线",
klineCount: 5,
expectZero: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
klines := generateTestKlines(tt.klineCount)
data := calculateIntradaySeries(klines)
if data == nil {
t.Fatal("calculateIntradaySeries returned nil")
}
if tt.expectZero && data.ATR14 != 0 {
t.Errorf("ATR14 = %.3f, expected 0 (insufficient data)", data.ATR14)
}
if tt.expectNonZero && data.ATR14 <= 0 {
t.Errorf("ATR14 = %.3f, expected > 0", data.ATR14)
}
})
}
}
// TestCalculateATR 测试 ATR 计算函数
func TestCalculateATR(t *testing.T) {
tests := []struct {
name string
klines []Kline
period int
expectZero bool
}{
{
name: "正常计算 - 足够数据",
klines: []Kline{
{High: 102.0, Low: 100.0, Close: 101.0},
{High: 103.0, Low: 101.0, Close: 102.0},
{High: 104.0, Low: 102.0, Close: 103.0},
{High: 105.0, Low: 103.0, Close: 104.0},
{High: 106.0, Low: 104.0, Close: 105.0},
{High: 107.0, Low: 105.0, Close: 106.0},
{High: 108.0, Low: 106.0, Close: 107.0},
{High: 109.0, Low: 107.0, Close: 108.0},
{High: 110.0, Low: 108.0, Close: 109.0},
{High: 111.0, Low: 109.0, Close: 110.0},
{High: 112.0, Low: 110.0, Close: 111.0},
{High: 113.0, Low: 111.0, Close: 112.0},
{High: 114.0, Low: 112.0, Close: 113.0},
{High: 115.0, Low: 113.0, Close: 114.0},
{High: 116.0, Low: 114.0, Close: 115.0},
},
period: 14,
expectZero: false,
},
{
name: "数据不足 - 等于period",
klines: []Kline{
{High: 102.0, Low: 100.0, Close: 101.0},
{High: 103.0, Low: 101.0, Close: 102.0},
},
period: 2,
expectZero: true,
},
{
name: "数据不足 - 少于period",
klines: []Kline{
{High: 102.0, Low: 100.0, Close: 101.0},
},
period: 14,
expectZero: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
atr := calculateATR(tt.klines, tt.period)
if tt.expectZero {
if atr != 0 {
t.Errorf("calculateATR() = %.3f, expected 0 (insufficient data)", atr)
}
} else {
if atr <= 0 {
t.Errorf("calculateATR() = %.3f, expected > 0", atr)
}
}
})
}
}
// TestCalculateATR_TrueRange 测试 ATR 的 True Range 计算正确性
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
}
atr := calculateATR(klines, 3)
// 期望的计算:
// TR[1] = max(51-49, |51-49|, |49-49|) = 2.0
// TR[2] = max(52-50, |52-50|, |50-50|) = 2.0
// TR[3] = max(53-51, |53-51|, |51-51|) = 2.0
// 初始 ATR = (2.0 + 2.0 + 2.0) / 3 = 2.0
// TR[4] = max(54-52, |54-52|, |52-52|) = 2.0
// 平滑 ATR = (2.0*2 + 2.0) / 3 = 2.0
expectedATR := 2.0
tolerance := 0.01 // 允许小的浮点误差
if math.Abs(atr-expectedATR) > tolerance {
t.Errorf("calculateATR() = %.3f, want approximately %.3f", atr, expectedATR)
}
}
// TestCalculateIntradaySeries_ConsistencyWithOtherIndicators 测试 Volume 和其他指标的一致性
func TestCalculateIntradaySeries_ConsistencyWithOtherIndicators(t *testing.T) {
klines := generateTestKlines(30)
data := calculateIntradaySeries(klines)
// 所有数组应该存在
if data.MidPrices == nil {
t.Error("MidPrices should not be nil")
}
if data.Volume == nil {
t.Error("Volume should not be nil")
}
// MidPrices 和 Volume 应该有相同的长度都是最近10个
if len(data.MidPrices) != len(data.Volume) {
t.Errorf("MidPrices length (%d) should equal Volume length (%d)",
len(data.MidPrices), len(data.Volume))
}
// 所有 Volume 值应该大于 0
for i, vol := range data.Volume {
if vol <= 0 {
t.Errorf("Volume[%d] = %.2f, should be > 0", i, vol)
}
}
}
// TestCalculateIntradaySeries_EmptyKlines 测试空 K线数据
func TestCalculateIntradaySeries_EmptyKlines(t *testing.T) {
klines := []Kline{}
data := calculateIntradaySeries(klines)
if data == nil {
t.Fatal("calculateIntradaySeries should not return nil for empty klines")
}
// 所有切片应该为空
if len(data.MidPrices) != 0 {
t.Errorf("MidPrices length = %d, want 0", len(data.MidPrices))
}
if len(data.Volume) != 0 {
t.Errorf("Volume length = %d, want 0", len(data.Volume))
}
// ATR14 应该为 0数据不足
if data.ATR14 != 0 {
t.Errorf("ATR14 = %.3f, want 0", data.ATR14)
}
}
// TestCalculateIntradaySeries_VolumePrecision 测试 Volume 精度保持
func TestCalculateIntradaySeries_VolumePrecision(t *testing.T) {
klines := []Kline{
{Close: 100.0, Volume: 1234.5678, High: 101.0, Low: 99.0},
{Close: 101.0, Volume: 9876.5432, High: 102.0, Low: 100.0},
{Close: 102.0, Volume: 5555.1111, High: 103.0, Low: 101.0},
}
data := calculateIntradaySeries(klines)
expectedVolumes := []float64{1234.5678, 9876.5432, 5555.1111}
for i, expected := range expectedVolumes {
if data.Volume[i] != expected {
t.Errorf("Volume[%d] = %.4f, want %.4f (precision not preserved)",
i, data.Volume[i], expected)
}
}
}
// 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")
}
}